From 1b422a1bebc6448caedd2ac44e9b28ffd9abd601 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 15 Sep 2022 14:11:17 +0100 Subject: [PATCH] add basic ws connectivity tests --- src/types/websockets.ts | 4 +- src/websocket-client.ts | 81 ++++++++++++++++++++------------ test/inverse/private.ws.test.ts | 75 ++++++++++++++++++++++++++++++ test/inverse/public.ws.test.ts | 75 ++++++++++++++++++++++++++++++ test/linear/private.ws.test.ts | 73 +++++++++++++++++++++++++++++ test/linear/public.ws.test.ts | 76 ++++++++++++++++++++++++++++++ test/ws.util.ts | 82 +++++++++++++++++++++++++++++++++ 7 files changed, 435 insertions(+), 31 deletions(-) create mode 100644 test/inverse/private.ws.test.ts create mode 100644 test/inverse/public.ws.test.ts create mode 100644 test/linear/private.ws.test.ts create mode 100644 test/linear/public.ws.test.ts create mode 100644 test/ws.util.ts diff --git a/src/types/websockets.ts b/src/types/websockets.ts index 856d537..4b713db 100644 --- a/src/types/websockets.ts +++ b/src/types/websockets.ts @@ -74,7 +74,7 @@ export type WsKey = typeof WS_KEY_MAP[keyof typeof WS_KEY_MAP]; export interface WSClientConfigurableOptions { key?: string; secret?: string; - livenet?: boolean; + testnet?: boolean; /** * The API group this client should connect to. @@ -94,7 +94,7 @@ export interface WSClientConfigurableOptions { } export interface WebsocketClientOptions extends WSClientConfigurableOptions { - livenet: boolean; + testnet?: boolean; market: APIMarket; pongTimeout: number; pingInterval: number; diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 2b966c9..1e81c01 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -37,16 +37,36 @@ function neverGuard(x: never, msg: string): Error { const loggerCategory = { category: 'bybit-ws' }; +export type WsClientEvent = + | 'open' + | 'update' + | 'close' + | 'error' + | 'reconnect' + | 'reconnected' + | 'response'; + +interface WebsocketClientEvents { + open: (evt: { wsKey: WsKey; event: any }) => void; + reconnect: (evt: { wsKey: WsKey; event: any }) => void; + reconnected: (evt: { wsKey: WsKey; event: any }) => void; + close: (evt: { wsKey: WsKey; event: any }) => void; + response: (response: any) => void; + update: (response: any) => void; + error: (response: any) => void; +} + +// Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837 export declare interface WebsocketClient { - on( - event: 'open' | 'reconnected', - listener: ({ wsKey: WsKey, event: any }) => void + on( + event: U, + listener: WebsocketClientEvents[U] ): this; - on( - event: 'response' | 'update' | 'error', - listener: (response: any) => void - ): this; - on(event: 'reconnect' | 'close', listener: ({ wsKey: WsKey }) => void): this; + + emit( + event: U, + ...args: Parameters + ): boolean; } export class WebsocketClient extends EventEmitter { @@ -65,7 +85,7 @@ export class WebsocketClient extends EventEmitter { this.wsStore = new WsStore(this.logger); this.options = { - livenet: false, + testnet: false, pongTimeout: 1000, pingInterval: 10000, reconnectTimeout: 500, @@ -89,7 +109,7 @@ export class WebsocketClient extends EventEmitter { this.restClient = new InverseClient( undefined, undefined, - this.isLivenet(), + !this.isTestnet(), this.options.restOptions, this.options.requestOptions ); @@ -99,7 +119,7 @@ export class WebsocketClient extends EventEmitter { this.restClient = new LinearClient( undefined, undefined, - this.isLivenet(), + !this.isTestnet(), this.options.restOptions, this.options.requestOptions ); @@ -109,7 +129,7 @@ export class WebsocketClient extends EventEmitter { this.restClient = new SpotClient( undefined, undefined, - this.isLivenet(), + !this.isTestnet(), this.options.restOptions, this.options.requestOptions ); @@ -134,8 +154,8 @@ export class WebsocketClient extends EventEmitter { } } - public isLivenet(): boolean { - return this.options.livenet === true; + public isTestnet(): boolean { + return this.options.testnet === true; } public isLinear(): boolean { @@ -216,6 +236,13 @@ export class WebsocketClient extends EventEmitter { this.getWs(wsKey)?.close(); } + public closeAll() { + const keys = this.wsStore.getKeys(); + keys.forEach((key) => { + this.close(key); + }); + } + /** * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library */ @@ -528,9 +555,8 @@ export class WebsocketClient extends EventEmitter { this.logger.info('Websocket connected', { ...loggerCategory, wsKey, - livenet: this.isLivenet(), - linear: this.isLinear(), - spot: this.isSpot(), + testnet: this.isTestnet(), + market: this.options.market, }); this.emit('open', { wsKey, event }); } else if ( @@ -560,7 +586,12 @@ export class WebsocketClient extends EventEmitter { const msg = JSON.parse((event && event.data) || event); if (msg['success'] || msg?.pong) { - return this.onWsMessageResponse(msg, wsKey); + if (isWsPong(msg)) { + this.logger.silly('Received pong', { ...loggerCategory, wsKey }); + } else { + this.emit('response', msg); + } + return; } if (msg.topic) { @@ -602,18 +633,10 @@ export class WebsocketClient extends EventEmitter { this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING ) { this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); - this.emit('reconnect', { wsKey }); + this.emit('reconnect', { wsKey, event }); } else { this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); - this.emit('close', { wsKey }); - } - } - - private onWsMessageResponse(response: any, wsKey: WsKey) { - if (isWsPong(response)) { - this.logger.silly('Received pong', { ...loggerCategory, wsKey }); - } else { - this.emit('response', response); + this.emit('close', { wsKey, event }); } } @@ -630,7 +653,7 @@ export class WebsocketClient extends EventEmitter { return this.options.wsUrl; } - const networkKey = this.isLivenet() ? 'livenet' : 'testnet'; + const networkKey = this.isTestnet() ? 'testnet' : 'livenet'; switch (wsKey) { case WS_KEY_MAP.linearPublic: { diff --git a/test/inverse/private.ws.test.ts b/test/inverse/private.ws.test.ts new file mode 100644 index 0000000..cb73e13 --- /dev/null +++ b/test/inverse/private.ws.test.ts @@ -0,0 +1,75 @@ +import { + WebsocketClient, + WSClientConfigurableOptions, + WS_KEY_MAP, +} from '../../src'; +import { + logAllEvents, + promiseSleep, + silentLogger, + waitForSocketEvent, + WS_OPEN_EVENT_PARTIAL, +} from '../ws.util'; + +describe('Private Inverse Perps Websocket Client', () => { + let wsClient: WebsocketClient; + + const API_KEY = process.env.API_KEY_COM; + const API_SECRET = process.env.API_SECRET_COM; + + it('should have api credentials to test with', () => { + expect(API_KEY).toStrictEqual(expect.any(String)); + expect(API_SECRET).toStrictEqual(expect.any(String)); + }); + + const wsClientOptions: WSClientConfigurableOptions = { + market: 'inverse', + key: API_KEY, + secret: API_SECRET, + }; + + beforeAll(() => { + wsClient = new WebsocketClient(wsClientOptions, silentLogger); + wsClient.connectPrivate(); + }); + + afterAll(() => { + // await promiseSleep(2000); + wsClient.closeAll(); + }); + + it('should open a ws connection', async () => { + const wsOpenPromise = waitForSocketEvent(wsClient, 'open'); + + expect(wsOpenPromise).resolves.toMatchObject({ + event: WS_OPEN_EVENT_PARTIAL, + wsKey: WS_KEY_MAP.inverse, + }); + + await Promise.all([wsOpenPromise]); + }); + + it('should subscribe to private wallet events', async () => { + const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); + // const wsUpdatePromise = waitForSocketEvent(wsClient, 'update'); + + const wsTopic = 'wallet'; + expect(wsResponsePromise).resolves.toMatchObject({ + request: { + args: [wsTopic], + op: 'subscribe', + }, + success: true, + }); + + // No easy way to trigger a private event (other than executing trades) + // expect(wsUpdatePromise).resolves.toMatchObject({ + // topic: wsTopic, + // data: expect.any(Array), + // }); + + wsClient.subscribe(wsTopic); + + await Promise.all([wsResponsePromise]); + }); +}); diff --git a/test/inverse/public.ws.test.ts b/test/inverse/public.ws.test.ts new file mode 100644 index 0000000..cb75af7 --- /dev/null +++ b/test/inverse/public.ws.test.ts @@ -0,0 +1,75 @@ +import { + LinearClient, + WebsocketClient, + WSClientConfigurableOptions, + WS_KEY_MAP, +} from '../../src'; +import { + promiseSleep, + silentLogger, + waitForSocketEvent, + WS_OPEN_EVENT_PARTIAL, +} from '../ws.util'; + +describe('Public Inverse Perps Websocket Client', () => { + let wsClient: WebsocketClient; + + const wsClientOptions: WSClientConfigurableOptions = { + market: 'inverse', + }; + + beforeAll(() => { + wsClient = new WebsocketClient(wsClientOptions, silentLogger); + wsClient.connectPublic(); + }); + + afterAll(() => { + wsClient.closeAll(); + }); + + it('should open a private ws connection', async () => { + const wsOpenPromise = waitForSocketEvent(wsClient, 'open'); + + expect(wsOpenPromise).resolves.toMatchObject({ + event: WS_OPEN_EVENT_PARTIAL, + wsKey: WS_KEY_MAP.inverse, + }); + + await Promise.all([wsOpenPromise]); + }); + + it('should subscribe to public orderBookL2_25 events', async () => { + const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); + const wsUpdatePromise = waitForSocketEvent(wsClient, 'update'); + + const wsTopic = 'orderBookL2_25.BTCUSD'; + expect(wsResponsePromise).resolves.toMatchObject({ + request: { + args: [wsTopic], + op: 'subscribe', + }, + success: true, + }); + expect(wsUpdatePromise).resolves.toMatchObject({ + topic: wsTopic, + data: expect.any(Array), + }); + + wsClient.subscribe(wsTopic); + + try { + await wsResponsePromise; + } catch (e) { + console.error( + `Wait for "${wsTopic}" subscription response exception: `, + e + ); + } + + try { + await wsUpdatePromise; + } catch (e) { + console.error(`Wait for "${wsTopic}" event exception: `, e); + } + }); +}); diff --git a/test/linear/private.ws.test.ts b/test/linear/private.ws.test.ts new file mode 100644 index 0000000..a87457e --- /dev/null +++ b/test/linear/private.ws.test.ts @@ -0,0 +1,73 @@ +import { + WebsocketClient, + WSClientConfigurableOptions, + WS_KEY_MAP, +} from '../../src'; +import { + promiseSleep, + silentLogger, + waitForSocketEvent, + WS_OPEN_EVENT_PARTIAL, +} from '../ws.util'; + +describe('Private Linear Websocket Client', () => { + let wsClient: WebsocketClient; + + const API_KEY = process.env.API_KEY_COM; + const API_SECRET = process.env.API_SECRET_COM; + + it('should have api credentials to test with', () => { + expect(API_KEY).toStrictEqual(expect.any(String)); + expect(API_SECRET).toStrictEqual(expect.any(String)); + }); + + const wsClientOptions: WSClientConfigurableOptions = { + market: 'linear', + key: API_KEY, + secret: API_SECRET, + }; + + beforeAll(() => { + wsClient = new WebsocketClient(wsClientOptions, silentLogger); + wsClient.connectPrivate(); + }); + + afterAll(() => { + wsClient.closeAll(); + }); + + it('should open a ws connection', async () => { + const wsOpenPromise = waitForSocketEvent(wsClient, 'open'); + + expect(wsOpenPromise).resolves.toMatchObject({ + event: WS_OPEN_EVENT_PARTIAL, + wsKey: WS_KEY_MAP.linearPrivate, + }); + + await Promise.all([wsOpenPromise]); + }); + + it('should subscribe to private wallet events', async () => { + const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); + // const wsUpdatePromise = waitForSocketEvent(wsClient, 'update'); + + const wsTopic = 'wallet'; + expect(wsResponsePromise).resolves.toMatchObject({ + request: { + args: [wsTopic], + op: 'subscribe', + }, + success: true, + }); + + // No easy way to trigger a private event (other than executing trades) + // expect(wsUpdatePromise).resolves.toMatchObject({ + // topic: wsTopic, + // data: expect.any(Array), + // }); + + wsClient.subscribe(wsTopic); + + await Promise.all([wsResponsePromise]); + }); +}); diff --git a/test/linear/public.ws.test.ts b/test/linear/public.ws.test.ts new file mode 100644 index 0000000..23e9ecf --- /dev/null +++ b/test/linear/public.ws.test.ts @@ -0,0 +1,76 @@ +import { + LinearClient, + WebsocketClient, + WSClientConfigurableOptions, + WS_KEY_MAP, +} from '../../src'; +import { + silentLogger, + waitForSocketEvent, + WS_OPEN_EVENT_PARTIAL, +} from '../ws.util'; + +describe('Public Linear Websocket Client', () => { + let wsClient: WebsocketClient; + + const wsClientOptions: WSClientConfigurableOptions = { + market: 'linear', + }; + + beforeAll(() => { + wsClient = new WebsocketClient(wsClientOptions, silentLogger); + wsClient.connectPublic(); + }); + + afterAll(() => { + wsClient.closeAll(); + }); + + it('should open a private ws connection', async () => { + const wsOpenPromise = waitForSocketEvent(wsClient, 'open'); + + expect(wsOpenPromise).resolves.toMatchObject({ + event: WS_OPEN_EVENT_PARTIAL, + wsKey: WS_KEY_MAP.linearPublic, + }); + + await Promise.all([wsOpenPromise]); + }); + + it('should subscribe to public orderBookL2_25 events', async () => { + const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); + const wsUpdatePromise = waitForSocketEvent(wsClient, 'update'); + + const wsTopic = 'orderBookL2_25.BTCUSDT'; + expect(wsResponsePromise).resolves.toMatchObject({ + request: { + args: [wsTopic], + op: 'subscribe', + }, + success: true, + }); + expect(wsUpdatePromise).resolves.toMatchObject({ + topic: wsTopic, + data: { + order_book: expect.any(Array), + }, + }); + + wsClient.subscribe(wsTopic); + + try { + await wsResponsePromise; + } catch (e) { + console.error( + `Wait for "${wsTopic}" subscription response exception: `, + e + ); + } + + try { + await wsUpdatePromise; + } catch (e) { + console.error(`Wait for "${wsTopic}" event exception: `, e); + } + }); +}); diff --git a/test/ws.util.ts b/test/ws.util.ts new file mode 100644 index 0000000..3eb19eb --- /dev/null +++ b/test/ws.util.ts @@ -0,0 +1,82 @@ +import { WebsocketClient, WsClientEvent } from '../src'; + +export const silentLogger = { + silly: () => {}, + debug: () => {}, + notice: () => {}, + info: () => {}, + warning: () => {}, + error: () => {}, +}; + +export const WS_OPEN_EVENT_PARTIAL = { + type: 'open', +}; + +/** Resolves a promise if an event is seen before a timeout (defaults to 2.5 seconds) */ +export function waitForSocketEvent( + wsClient: WebsocketClient, + event: WsClientEvent, + timeoutMs: number = 4.5 * 1000 +) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + `Failed to receive "${event}" event before timeout. Check that these are correct: topic, api keys (if private), signature process (if private)` + ); + }, timeoutMs); + + let resolvedOnce = false; + + wsClient.on(event, (event) => { + clearTimeout(timeout); + resolve(event); + resolvedOnce = true; + }); + + wsClient.on('error', (event) => { + clearTimeout(timeout); + if (!resolvedOnce) { + reject(event); + } + }); + + // if (event !== 'close') { + // wsClient.on('close', (event) => { + // clearTimeout(timeout); + + // if (!resolvedOnce) { + // reject(event); + // } + // }); + // } + }); +} + +export function logAllEvents(wsClient: WebsocketClient) { + wsClient.on('update', (data) => { + console.log('wsUpdate: ', JSON.stringify(data, null, 2)); + }); + + wsClient.on('open', (data) => { + console.log('wsOpen: ', data.wsKey); + }); + wsClient.on('response', (data) => { + console.log('wsResponse ', JSON.stringify(data, null, 2)); + }); + wsClient.on('reconnect', ({ wsKey }) => { + console.log('wsReconnecting ', wsKey); + }); + wsClient.on('reconnected', (data) => { + console.log('wsReconnected ', data?.wsKey); + }); + wsClient.on('close', (data) => { + // console.log('wsClose: ', data); + }); +} + +export function promiseSleep(ms: number) { + return new Promise((resolve, reject) => { + setTimeout(resolve, ms); + }); +}