From 29262769c09d31a843599c7272929d4fb263e01c Mon Sep 17 00:00:00 2001 From: Adhwaith Menon <111346225+adhmenon@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:14:43 +0530 Subject: [PATCH] feat(cc-sdk): added-health-check-and-keep-alives (#2) * feat(cc-sdk): ported-code-for-health-check * feat(cc-sdk): fixed-broken-tests * feat(cc-sdk): fixed-blob-issue * feat(cc-sdk): merged-with-ravi-pr * feat(cc-sdk): re-added-cc-code * feat(cc-sdk): added-logger-proxy * feat(cc-sdk): added-config-values * feat(cc-sdk): added-connection-instance * feat(cc-sdk): added-event-listeners-instead-of-signals * feat(cc-sdk): fixed-broken-tests * feat(cc-sdk): fixed-test-coverage --- .../@webex/plugin-cc/__mocks__/workerMock.js | 15 + packages/@webex/plugin-cc/src/cc.ts | 40 +- .../src/services/core/HttpRequest.ts | 71 +-- .../core/WebSocket/WebSocketManager.ts | 170 +++++++ .../src/services/core/WebSocket/config.ts | 47 -- .../core/WebSocket/connection-service.ts | 144 ++++++ .../src/services/core/WebSocket/index.ts | 70 --- .../core/WebSocket/keepalive.worker.js | 88 ++++ .../src/services/core/WebSocket/types.ts | 45 -- .../plugin-cc/src/services/core/aqm-reqs.ts | 23 +- .../plugin-cc/src/services/core/constants.ts | 4 + .../@webex/plugin-cc/src/services/index.ts | 9 +- packages/@webex/plugin-cc/src/types.ts | 2 +- .../@webex/plugin-cc/test/unit/spec/cc.ts | 25 +- .../test/unit/spec/services/agent/index.ts | 2 +- .../unit/spec/services/core/HttpRequest.ts | 80 ++-- .../spec/services/core/WebSocket/WebSocket.ts | 133 ------ .../core/WebSocket/WebSocketManager.ts | 198 +++++++++ .../core/WebSocket/connection-service.ts | 62 +++ .../test/unit/spec/services/core/aqm-reqs.ts | 420 ++++++++++++------ 20 files changed, 1072 insertions(+), 576 deletions(-) create mode 100644 packages/@webex/plugin-cc/__mocks__/workerMock.js create mode 100644 packages/@webex/plugin-cc/src/services/core/WebSocket/WebSocketManager.ts delete mode 100644 packages/@webex/plugin-cc/src/services/core/WebSocket/config.ts create mode 100644 packages/@webex/plugin-cc/src/services/core/WebSocket/connection-service.ts delete mode 100644 packages/@webex/plugin-cc/src/services/core/WebSocket/index.ts create mode 100644 packages/@webex/plugin-cc/src/services/core/WebSocket/keepalive.worker.js delete mode 100644 packages/@webex/plugin-cc/src/services/core/WebSocket/types.ts delete mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocket.ts create mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocketManager.ts create mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/connection-service.ts diff --git a/packages/@webex/plugin-cc/__mocks__/workerMock.js b/packages/@webex/plugin-cc/__mocks__/workerMock.js new file mode 100644 index 00000000000..7eb5a07eb16 --- /dev/null +++ b/packages/@webex/plugin-cc/__mocks__/workerMock.js @@ -0,0 +1,15 @@ +class Worker { + constructor(stringUrl) { + this.url = stringUrl; + this.onmessage = () => {}; + } + + postMessage(msg) { + this.onmessage(msg); + } + + terminate() {} +} + +global.Worker = Worker; +global.URL.createObjectURL = jest.fn(() => 'blob:http://localhost:3000/12345'); diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/plugin-cc/src/cc.ts index 0aaa75f925e..2c6c2cae736 100644 --- a/packages/@webex/plugin-cc/src/cc.ts +++ b/packages/@webex/plugin-cc/src/cc.ts @@ -11,13 +11,16 @@ import { StationLoginResponse, StationLogoutResponse, StationReLoginResponse, + SubscribeRequest, } from './types'; import {READY, CC_FILE, EMPTY_STRING} from './constants'; import HttpRequest from './services/core/HttpRequest'; import WebCallingService from './services/WebCallingService'; import {AGENT, WEB_RTC_PREFIX} from './services/constants'; +import {WebSocketManager} from './services/core/WebSocket/WebSocketManager'; import Services from './services'; import LoggerProxy from './logger-proxy'; +import {ConnectionService} from './services/core/WebSocket/connection-service'; import {Logout} from './services/agent/types'; import {getErrorDetails} from './services/core/Utils'; @@ -26,9 +29,10 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter private $config: CCPluginConfig; private $webex: WebexSDK; private agentConfig: IAgentProfile; - private registered = false; private httpRequest: HttpRequest; + private webSocketManager: WebSocketManager; private webCallingService: WebCallingService; + private connectionService: ConnectionService; private services: Services; constructor(...args) { @@ -48,7 +52,14 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter webex: this.$webex, }); - this.services = Services.getInstance(); + this.webSocketManager = new WebSocketManager({webex: this.$webex}); + + this.connectionService = new ConnectionService( + this.webSocketManager, + this.getConnectionConfig() + ); + + this.services = Services.getInstance(this.webSocketManager); this.webCallingService = new WebCallingService(this.$webex, this.$config.callingClientConfig); @@ -76,17 +87,10 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter * @private */ private async connectWebsocket() { - const connectionConfig = { - force: this.$config?.force ?? true, - isKeepAliveEnabled: this.$config?.isKeepAliveEnabled ?? false, - clientType: this.$config?.clientType ?? 'WebexCCSDK', - allowMultiLogin: this.$config?.allowMultiLogin ?? true, - }; - try { - return this.httpRequest - .subscribeNotifications({ - body: connectionConfig, + return this.webSocketManager + .initWebSocket({ + body: this.getConnectionConfig(), }) .then(async (data: WelcomeEvent) => { const agentId = data.agentId; @@ -187,4 +191,16 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter return WEB_RTC_PREFIX + this.agentConfig.agentId; } + + /** + * This method returns the connection configuration. + */ + private getConnectionConfig(): SubscribeRequest { + return { + force: this.$config?.force ?? true, + isKeepAliveEnabled: this.$config?.isKeepAliveEnabled ?? false, + clientType: this.$config?.clientType ?? 'WebexCCSDK', + allowMultiLogin: this.$config?.allowMultiLogin ?? true, + }; + } } diff --git a/packages/@webex/plugin-cc/src/services/core/HttpRequest.ts b/packages/@webex/plugin-cc/src/services/core/HttpRequest.ts index b11beed127e..2d320d58c02 100644 --- a/packages/@webex/plugin-cc/src/services/core/HttpRequest.ts +++ b/packages/@webex/plugin-cc/src/services/core/HttpRequest.ts @@ -1,24 +1,7 @@ -import {SUBSCRIBE_API, WCC_API_GATEWAY, WEBSOCKET_EVENT_TIMEOUT} from '../constants'; -import { - WebexSDK, - HTTP_METHODS, - SubscribeRequest, - IHttpResponse, - WelcomeResponse, - WelcomeEvent, - RequestBody, -} from '../../types'; -import IWebSocket from '../WebSocket/types'; -import WebSocket from '../WebSocket'; -import {CC_EVENTS, SubscribeResponse} from '../config/types'; -import {EVENT} from '../../constants'; - -export type EventHandler = {(data: any): void}; +import {WebexSDK, HTTP_METHODS, IHttpResponse, RequestBody} from '../../types'; class HttpRequest { - private webSocket: IWebSocket; private webex: WebexSDK; - private eventHandlers: Map; private static instance: HttpRequest; public static getInstance(options?: {webex: WebexSDK}): HttpRequest { @@ -32,58 +15,6 @@ class HttpRequest { private constructor(options: {webex: WebexSDK}) { const {webex} = options; this.webex = webex; - this.webSocket = new WebSocket({ - parent: this.webex, - }); - this.eventHandlers = new Map(); - - // Centralized WebSocket event listener - this.webSocket.on(EVENT, (eventData) => { - this.webex.logger.log(`Received event: ${eventData.type}`); - const handler = this.eventHandlers.get(eventData.type); - if (handler) { - handler(eventData.data); - } - }); - } - - public getWebSocket(): IWebSocket { - return this.webSocket; - } - - /* This calls subscribeNotifications and establishes a websocket connection - * It sends the request and then listens for the Welcome event - * If the Welcome event is received, it resolves the promise - * If the Welcome event is not received, it rejects the promise - */ - public async subscribeNotifications(options: {body: SubscribeRequest}): Promise { - const {body} = options; - const eventType = CC_EVENTS.WELCOME; - const subscribeResponse: SubscribeResponse = await this.webex.request({ - service: WCC_API_GATEWAY, - resource: SUBSCRIBE_API, - method: HTTP_METHODS.POST, - body, - }); - - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - this.webex.logger.error('Timeout waiting for event'); - this.eventHandlers.delete(eventType); - reject(new Error('Timeout waiting for event')); - }, WEBSOCKET_EVENT_TIMEOUT); - - // Store the event handler - this.eventHandlers.set(eventType, (data: WelcomeEvent) => { - clearTimeout(timeoutId); - this.eventHandlers.delete(eventType); - resolve(data); - }); - - this.webSocket.connectWebSocket({ - webSocketUrl: subscribeResponse.body.webSocketUrl, - }); - }); } public async request(options: { diff --git a/packages/@webex/plugin-cc/src/services/core/WebSocket/WebSocketManager.ts b/packages/@webex/plugin-cc/src/services/core/WebSocket/WebSocketManager.ts new file mode 100644 index 00000000000..bb42a3b6c18 --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/core/WebSocket/WebSocketManager.ts @@ -0,0 +1,170 @@ +import {WebexSDK, SubscribeRequest, HTTP_METHODS, WelcomeResponse} from '../../../types'; +import {SUBSCRIBE_API, WCC_API_GATEWAY} from '../../constants'; +import {SubscribeResponse} from '../../config/types'; +import LoggerProxy from '../../../logger-proxy'; +import workerScript from './keepalive.worker'; +import {KEEPALIVE_WORKER_INTERVAL, CLOSE_SOCKET_TIMEOUT} from '../constants'; + +export class WebSocketManager extends EventTarget { + private websocket: WebSocket; + shouldReconnect: boolean; + isSocketClosed: boolean; + private isWelcomeReceived: boolean; + private url: string | null = null; + private forceCloseWebSocketOnTimeout: boolean; + private isConnectionLost: boolean; + private webex: WebexSDK; + private welcomePromiseResolve: + | ((value: WelcomeResponse | PromiseLike) => void) + | null = null; + + private keepaliveWorker: Worker; + + constructor(options: {webex: WebexSDK}) { + super(); + const {webex} = options; + this.webex = webex; + this.shouldReconnect = true; + this.websocket = {} as WebSocket; + this.isSocketClosed = false; + this.isWelcomeReceived = false; + this.forceCloseWebSocketOnTimeout = false; + this.isConnectionLost = false; + + const workerScriptBlob = new Blob([workerScript], {type: 'application/javascript'}); + this.keepaliveWorker = new Worker(URL.createObjectURL(workerScriptBlob)); + } + + async initWebSocket(options: {body: SubscribeRequest}): Promise { + const connectionConfig = options.body; + await this.register(connectionConfig); + + return new Promise((resolve, reject) => { + this.welcomePromiseResolve = resolve; + this.connect().catch((error) => { + LoggerProxy.logger.error(`[WebSocketStatus] | Error in connecting Websocket ${error}`); + reject(error); + }); + }); + } + + close(shouldReconnect: boolean, reason = 'Unknown') { + if (!this.isSocketClosed && this.shouldReconnect) { + this.shouldReconnect = shouldReconnect; + this.websocket.close(); + this.keepaliveWorker.postMessage({type: 'terminate'}); + LoggerProxy.logger.error( + `[WebSocketStatus] | event=webSocketClose | WebSocket connection closed manually REASON: ${reason}` + ); + } + } + + private async register(connectionConfig: SubscribeRequest) { + try { + const subscribeResponse: SubscribeResponse = await this.webex.request({ + service: WCC_API_GATEWAY, + resource: SUBSCRIBE_API, + method: HTTP_METHODS.POST, + body: connectionConfig, + }); + this.url = subscribeResponse.body.webSocketUrl; + } catch (e) { + LoggerProxy.logger.error( + `Register API Failed, Request to RoutingNotifs websocket registration API failed ${e}` + ); + } + } + + private async connect() { + if (!this.url) { + return undefined; + } + LoggerProxy.logger.log( + `[WebSocketStatus] | event=webSocketConnecting | Connecting to WebSocket: ${this.url}` + ); + this.websocket = new WebSocket(this.url); + + return new Promise((resolve, reject) => { + this.websocket.onopen = () => { + this.isSocketClosed = false; + this.shouldReconnect = true; + + this.websocket.send(JSON.stringify({keepalive: 'true'})); + this.keepaliveWorker.onmessage = (keepAliveEvent: {data: any}) => { + if (keepAliveEvent?.data?.type === 'keepalive') { + this.websocket.send(JSON.stringify({keepalive: 'true'})); + } + + if (keepAliveEvent?.data?.type === 'closeSocket' && this.isConnectionLost) { + this.forceCloseWebSocketOnTimeout = true; + this.close(true, 'WebSocket did not auto close within 16 secs'); + LoggerProxy.logger.error( + '[webSocketTimeout] | event=webSocketTimeout | WebSocket connection closed forcefully' + ); + } + }; + + this.keepaliveWorker.postMessage({ + type: 'start', + intervalDuration: KEEPALIVE_WORKER_INTERVAL, // Keepalive interval + isSocketClosed: this.isSocketClosed, + closeSocketTimeout: CLOSE_SOCKET_TIMEOUT, // Close socket timeout + }); + }; + + this.websocket.onerror = (event: any) => { + LoggerProxy.logger.error( + `[WebSocketStatus] | event=socketConnectionFailed | WebSocket connection failed ${event}` + ); + reject(); + }; + + this.websocket.onclose = async (event: any) => { + this.webSocketOnCloseHandler(event); + }; + + this.websocket.onmessage = (e: MessageEvent) => { + this.dispatchEvent(new CustomEvent('message', {detail: e.data})); + const eventData = JSON.parse(e.data); + + if (eventData.type === 'Welcome') { + this.isWelcomeReceived = true; + if (this.welcomePromiseResolve) { + this.welcomePromiseResolve(eventData.data as WelcomeResponse); + this.welcomePromiseResolve = null; + } + } + + if (eventData.type === 'AGENT_MULTI_LOGIN') { + this.close(false, 'multiLogin'); + LoggerProxy.logger.error( + '[WebSocketStatus] | event=agentMultiLogin | WebSocket connection closed by agent multiLogin' + ); + } + }; + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private async webSocketOnCloseHandler(event: any) { + this.isSocketClosed = true; + this.keepaliveWorker.postMessage({type: 'terminate'}); + if (this.shouldReconnect) { + this.dispatchEvent(new Event('socketClose')); + let issueReason; + if (this.forceCloseWebSocketOnTimeout) { + issueReason = 'WebSocket auto close timed out. Forcefully closed websocket.'; + } else { + const onlineStatus = navigator.onLine; + LoggerProxy.logger.info(`[WebSocketStatus] | desktop online status is ${onlineStatus}`); + issueReason = !onlineStatus + ? 'network issue' + : 'missing keepalive from either desktop or notif service'; + } + LoggerProxy.logger.error( + `[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: ${issueReason}` + ); + this.forceCloseWebSocketOnTimeout = false; + } + } +} diff --git a/packages/@webex/plugin-cc/src/services/core/WebSocket/config.ts b/packages/@webex/plugin-cc/src/services/core/WebSocket/config.ts deleted file mode 100644 index 12fde7a5a18..00000000000 --- a/packages/@webex/plugin-cc/src/services/core/WebSocket/config.ts +++ /dev/null @@ -1,47 +0,0 @@ -const webSocketConfig = { - /** - * Milliseconds between pings sent up the socket - * @type {number} - */ - pingInterval: process.env.MERCURY_PING_INTERVAL || 15000, - /** - * Milliseconds to wait for a pong before declaring the connection dead - * @type {number} - */ - pongTimeout: process.env.MERCURY_PONG_TIMEOUT || 14000, - /** - * Maximum milliseconds between connection attempts - * @type {Number} - */ - backoffTimeMax: process.env.MERCURY_BACKOFF_TIME_MAX || 32000, - /** - * Initial milliseconds between connection attempts - * @type {Number} - */ - backoffTimeReset: process.env.MERCURY_BACKOFF_TIME_RESET || 1000, - /** - * Milliseconds to wait for a close frame before declaring the socket dead and - * discarding it - * @type {[type]} - */ - forceCloseDelay: process.env.MERCURY_FORCE_CLOSE_DELAY || 2000, - /** - * When logging out, use default reason which can trigger a reconnect, - * or set to something else, like `done (permanent)` to prevent reconnect - * @type {String} - */ - beforeLogoutOptionsCloseReason: process.env.MERCURY_LOGOUT_REASON || 'done (forced)', - - /** - * Whether or not to authorize the websocket connection with the user's token - * - */ - authorizationRequired: false, - /** - * Whether or not to acknowledge the messenges received from the websocket - * - */ - acknowledgementRequired: false, -}; - -export default webSocketConfig; diff --git a/packages/@webex/plugin-cc/src/services/core/WebSocket/connection-service.ts b/packages/@webex/plugin-cc/src/services/core/WebSocket/connection-service.ts new file mode 100644 index 00000000000..0d0184fd65e --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/core/WebSocket/connection-service.ts @@ -0,0 +1,144 @@ +import {WebSocketManager} from './WebSocketManager'; +import {SubscribeRequest} from '../../../types'; +import LoggerProxy from '../../../logger-proxy'; +import { + LOST_CONNECTION_RECOVERY_TIMEOUT, + WS_DISCONNECT_ALLOWED, + CONNECTIVITY_CHECK_INTERVAL, +} from '../constants'; + +type ConnectionLostDetails = { + isConnectionLost: boolean; + isRestoreFailed: boolean; + isSocketReconnected: boolean; + isKeepAlive: boolean; +}; + +type ConnectionProp = { + lostConnectionRecoveryTimeout: number; +}; + +export class ConnectionService extends EventTarget { + private connectionProp: ConnectionProp = { + lostConnectionRecoveryTimeout: LOST_CONNECTION_RECOVERY_TIMEOUT, + }; + + private wsDisconnectAllowed = WS_DISCONNECT_ALLOWED; + private reconnectingTimer: ReturnType; + private restoreTimer: ReturnType; + private isConnectionLost: boolean; + private isRestoreFailed: boolean; + private isSocketReconnected: boolean; + private isKeepAlive: boolean; + private reconnectInterval: ReturnType; + private webSocketManager: WebSocketManager; + private subscribeRequest: SubscribeRequest; + + constructor(webSocketManager: WebSocketManager, subscribeRequest: SubscribeRequest) { + super(); + this.webSocketManager = webSocketManager; + this.subscribeRequest = subscribeRequest; + + this.isConnectionLost = false; + this.isRestoreFailed = false; + this.isSocketReconnected = false; + this.isKeepAlive = false; + + this.webSocketManager.addEventListener('message', this.onPing); + this.webSocketManager.addEventListener('socketClose', this.onSocketClose); + } + + private dispatchConnectionEvent(socketReconnected = false): void { + const event = new CustomEvent('connectionLost', { + detail: { + isConnectionLost: this.isConnectionLost, + isRestoreFailed: this.isRestoreFailed, + isSocketReconnected: + !this.webSocketManager.isSocketClosed && (socketReconnected || this.isSocketReconnected), + isKeepAlive: this.isKeepAlive, + }, + }); + this.dispatchEvent(event); + } + + private handleConnectionLost = (): void => { + this.isConnectionLost = true; + this.dispatchConnectionEvent(); + }; + + private clearTimerOnRestoreFailed = async () => { + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + } + }; + + private handleRestoreFailed = async () => { + this.isRestoreFailed = true; + this.webSocketManager.shouldReconnect = false; + this.dispatchConnectionEvent(); + await this.clearTimerOnRestoreFailed(); + }; + + private updateConnectionData = (): void => { + this.isRestoreFailed = false; + this.isConnectionLost = false; + this.isSocketReconnected = false; + }; + + public setConnectionProp(prop: ConnectionProp): void { + this.connectionProp = prop; + } + + private onPing = (event: Event): void => { + const msg = (event as CustomEvent).detail; + const parsedEvent = JSON.parse(msg); + if (this.reconnectingTimer) { + clearTimeout(this.reconnectingTimer); + } + if (this.restoreTimer) { + clearTimeout(this.restoreTimer); + } + this.isKeepAlive = parsedEvent.keepalive === 'true'; + const shouldUpdateConnectionData = + this.isKeepAlive || (this.isConnectionLost && !this.isRestoreFailed); + const shouldDispatchEvent = + this.isKeepAlive || (this.isConnectionLost && !this.isRestoreFailed); + const shouldDispatchEventWithReconnect = this.isSocketReconnected && this.isKeepAlive; + + if (shouldUpdateConnectionData) { + this.updateConnectionData(); + } + + if (shouldDispatchEvent) { + this.dispatchConnectionEvent(); + } else if (shouldDispatchEventWithReconnect) { + this.dispatchConnectionEvent(true); + } + + this.reconnectingTimer = setTimeout(this.handleConnectionLost, this.wsDisconnectAllowed); + this.restoreTimer = setTimeout( + this.handleRestoreFailed, + this.connectionProp && this.connectionProp.lostConnectionRecoveryTimeout + ); + }; + + private handleSocketClose = async (): Promise => { + LoggerProxy.logger.info(`event=socketConnectionRetry | Trying to reconnect to notifs socket`); + const onlineStatus = navigator.onLine; + if (onlineStatus) { + await this.webSocketManager.initWebSocket({body: this.subscribeRequest}); + await this.clearTimerOnRestoreFailed(); + this.isSocketReconnected = true; + } else { + throw new Error('event=socketConnectionRetry | browser network not available'); + } + }; + + private onSocketClose = (): void => { + this.clearTimerOnRestoreFailed(); + + this.reconnectInterval = setInterval(async () => { + await this.handleSocketClose(); + }, CONNECTIVITY_CHECK_INTERVAL); + }; +} diff --git a/packages/@webex/plugin-cc/src/services/core/WebSocket/index.ts b/packages/@webex/plugin-cc/src/services/core/WebSocket/index.ts deleted file mode 100644 index 14380c08567..00000000000 --- a/packages/@webex/plugin-cc/src/services/core/WebSocket/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import Mercury from '@webex/internal-plugin-mercury'; -import {WebSocketEvent} from '../../config/types'; -import webSocketConfig from './config'; -import IWebSocket from './types'; - -class WebSocket extends (Mercury as any) implements IWebSocket { - /** - * @instance - * @memberof WebSocket - * @private - * @type {string} - */ - private webSocketUrl: string; - - config = webSocketConfig; // overriding the config of Mercury with CC config - - constructor(options = {}) { - super(options); - Mercury.prototype.initialize(this, options); - } - - on(event: string, callback: (event: WebSocketEvent) => void): void { - super.on(event, callback); - } - - off(event: string, callback: (event: WebSocketEvent) => void): void { - super.off(event, callback); - } - - /** - * Subscribe and connect to the websocket - * @param {object} params - * @param {string} params.datachannelUrl - * @param {SubscribeRequest} params.body - * @returns {Promise} - */ - connectWebSocket(options: {webSocketUrl: string}): void { - const {webSocketUrl} = options; - this.webSocketUrl = webSocketUrl; - this.connect(webSocketUrl); - } - - /** - * Tells if WebSocket socket is connected - * @returns {boolean} connected - */ - isConnected(): boolean { - return this.connected; - } - - /** - * Get data channel URL for the connection - * @returns {string} data channel Url - */ - getWebSocketUrl(): string | undefined { - return this.webSocketUrl; - } - - /** - * Disconnects websocket connection - * @returns {Promise} - */ - disconnectWebSocket(): Promise { - return this.disconnect().then(() => { - this.webSocketUrl = undefined; - }); - } -} - -export default WebSocket; diff --git a/packages/@webex/plugin-cc/src/services/core/WebSocket/keepalive.worker.js b/packages/@webex/plugin-cc/src/services/core/WebSocket/keepalive.worker.js new file mode 100644 index 00000000000..1eaac795aeb --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/core/WebSocket/keepalive.worker.js @@ -0,0 +1,88 @@ +// TODO: Try to find alternative to using Blob and script here +const workerScript = ` +console.log("*** Keepalive Worker Thread ***"); +let intervalId, intervalDuration, timeOutId, isSocketClosed, closeSocketTimeout; +let initialised = false; +let initiateWebSocketClosure = false; + +const resetOfflineHandler = function () { + if (timeOutId) { + initialised = false; + clearTimeout(timeOutId); + timeOutId = null; + } +}; + +const checkOnlineStatus = function () { + const onlineStatus = navigator.onLine; + console.log( + \`[WebSocketStatus] event=checkOnlineStatus | timestamp=${new Date()}, UTC=${new Date().toUTCString()} | online status=\`, + onlineStatus + ); + return onlineStatus; +}; + +// Checks network status and if it's offline then force closes WebSocket +const checkNetworkStatus = function () { + const onlineStatus = checkOnlineStatus(); + postMessage({ type: "keepalive", onlineStatus }); + if (!onlineStatus && !initialised) { + initialised = true; + // Sets a timeout of 16s, checks if socket didn't close then it closes forcefully + timeOutId = setTimeout(() => { + if (!isSocketClosed) { + initiateWebSocketClosure = true; + postMessage({ type: "closeSocket" }); + } + }, closeSocketTimeout); + } + + if (onlineStatus && initialised) { + initialised = false; + } + + if (initiateWebSocketClosure) { + initiateWebSocketClosure = false; + clearTimeout(timeOutId); + timeOutId = null; + } +}; + +addEventListener("message", (event) => { + if (event.data?.type === "start") { + intervalDuration = event.data?.intervalDuration || 4000; + closeSocketTimeout = event.data?.closeSocketTimeout || 5000; + console.log("event=Websocket startWorker | keepalive Worker started"); + intervalId = setInterval( + (checkIfSocketClosed) => { + checkNetworkStatus(); + isSocketClosed = checkIfSocketClosed; + }, + intervalDuration, + event.data?.isSocketClosed + ); + + resetOfflineHandler(); + } + + if (event.data?.type === "terminate" && intervalId) { + console.log("event=Websocket terminateWorker | keepalive Worker stopped"); + clearInterval(intervalId); + intervalId = null; + resetOfflineHandler(); + } +}); + +// Listen for online and offline events +self.addEventListener('online', () => { + console.log('Network status: online'); + checkNetworkStatus(); +}); + +self.addEventListener('offline', () => { + console.log('Network status: offline'); + checkNetworkStatus(); +}); +`; + +export default workerScript; diff --git a/packages/@webex/plugin-cc/src/services/core/WebSocket/types.ts b/packages/@webex/plugin-cc/src/services/core/WebSocket/types.ts deleted file mode 100644 index c654e979334..00000000000 --- a/packages/@webex/plugin-cc/src/services/core/WebSocket/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {WebSocketEvent} from '../../config/types'; - -// ts doc -/** - * Interface for WebSocket - */ -interface IWebSocket { - /** - * Subscribe to the WebSocket events - * @param {string} event - * @param {function} callback - * @returns {void} - */ - on(event: string, callback: (event: WebSocketEvent) => void): void; - /** - * Unsubscribe from the WebSocket events - * @param {string} event - * @param {function} callback - * @returns {void} - */ - off(event: string, callback: (event: WebSocketEvent) => void): void; - /** - * Subscribe and connect to the WebSocket - * @param {object} options - * @returns {void} - */ - connectWebSocket(options: {webSocketUrl: string}): void; - /** - * Check if the WebSocket connection is connected - * @returns {boolean} - */ - isConnected(): boolean; - /** - * Disconnect the WebSocket connection - * @returns {Promise} - */ - disconnectWebSocket(): Promise; - /** - * Get data channel URL for the connection - * @returns {string} data channel Url - */ - getWebSocketUrl(): string | undefined; -} - -export default IWebSocket; diff --git a/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts b/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts index 057f041ac80..762d51589e5 100644 --- a/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts +++ b/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts @@ -1,6 +1,7 @@ import {Msg} from './GlobalTypes'; import * as Err from './Err'; import {HTTP_METHODS, WebexRequestPayload} from '../../types'; +import {WebSocketManager} from './WebSocket/WebSocketManager'; import HttpRequest from './HttpRequest'; import LoggerProxy from '../../logger-proxy'; import {CbRes, Conf, ConfEmpty, Pending, Req, Res, ResEmpty} from './types'; @@ -9,13 +10,13 @@ import {TIMEOUT_REQ} from './constants'; export default class AqmReqs { private pendingRequests: Record = {}; private pendingNotifCancelrequest: Record = {}; + private webSocketManager: WebSocketManager; private httpRequest: HttpRequest; - constructor() { + + constructor(webSocketManager: WebSocketManager) { this.httpRequest = HttpRequest.getInstance(); - this.httpRequest.getWebSocket().on('event', (eventData) => { - LoggerProxy.logger.log(`Received event: ${eventData.type}`); - this.onMessage(eventData); - }); + this.webSocketManager = webSocketManager; + this.webSocketManager.addEventListener('message', this.onMessage); } req(c: Conf): Res { @@ -201,16 +202,16 @@ export default class AqmReqs { } // must be lambda - private readonly onMessage = (event: any) => { - // const event = JSON.parse(msg); + private readonly onMessage = (msg: any) => { + const event = JSON.parse(msg.detail); if (event.type === 'Welcome') { - LoggerProxy.logger.info(`Welcome message from Notifs Websocket${event}`); + LoggerProxy.logger.info(`Welcome message from Notifs Websocket`); return; } - if (event.keepalive) { - LoggerProxy.logger.info(`Keepalive from notifs${event}`); + if (event.keepalive === 'true') { + LoggerProxy.logger.info(`Keepalive from web socket`); return; } @@ -240,7 +241,7 @@ export default class AqmReqs { if (!isHandled) { LoggerProxy.logger.info( - `event=missingEventHandler | [AqmReqs] missing routing message handler${event}` + `event=missingEventHandler | [AqmReqs] missing routing message handler` ); } }; diff --git a/packages/@webex/plugin-cc/src/services/core/constants.ts b/packages/@webex/plugin-cc/src/services/core/constants.ts index 59dd0c0a434..a5c6cc95ab8 100644 --- a/packages/@webex/plugin-cc/src/services/core/constants.ts +++ b/packages/@webex/plugin-cc/src/services/core/constants.ts @@ -5,3 +5,7 @@ export const PING_API_URL = '/health'; export const WELCOME_TIMEOUT = 30000; export const RTD_PING_EVENT = 'rtd-online-status'; export const TIMEOUT_REQ = 20000; +export const LOST_CONNECTION_RECOVERY_TIMEOUT = 20000; +export const WS_DISCONNECT_ALLOWED = 8000; +export const CONNECTIVITY_CHECK_INTERVAL = 5000; +export const CLOSE_SOCKET_TIMEOUT = 16000; diff --git a/packages/@webex/plugin-cc/src/services/index.ts b/packages/@webex/plugin-cc/src/services/index.ts index 6462c0d221a..67fc11023b7 100644 --- a/packages/@webex/plugin-cc/src/services/index.ts +++ b/packages/@webex/plugin-cc/src/services/index.ts @@ -1,18 +1,19 @@ import routingAgent from './agent'; import AqmReqs from './core/aqm-reqs'; +import {WebSocketManager} from './core/WebSocket/WebSocketManager'; export default class Services { public readonly agent: ReturnType; private static instance: Services; - constructor() { - const aqmReq = new AqmReqs(); + constructor(webSocketManager: WebSocketManager) { + const aqmReq = new AqmReqs(webSocketManager); this.agent = routingAgent(aqmReq); } - public static getInstance(): Services { + public static getInstance(webSocketManager: WebSocketManager): Services { if (!this.instance) { - this.instance = new Services(); + this.instance = new Services(webSocketManager); } return this.instance; diff --git a/packages/@webex/plugin-cc/src/types.ts b/packages/@webex/plugin-cc/src/types.ts index 36e2ac9c859..17574463a9a 100644 --- a/packages/@webex/plugin-cc/src/types.ts +++ b/packages/@webex/plugin-cc/src/types.ts @@ -1,4 +1,4 @@ -import {CallingClientConfig} from '@webex/calling/dist/types/CallingClient/types'; +import {CallingClientConfig} from '@webex/calling'; import * as Agent from './services/agent/types'; type Enum> = T[keyof T]; diff --git a/packages/@webex/plugin-cc/test/unit/spec/cc.ts b/packages/@webex/plugin-cc/test/unit/spec/cc.ts index ca74591e796..ba6d9f5693b 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/cc.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/cc.ts @@ -9,6 +9,9 @@ import Services from '../../../src/services'; import config from '../../../src/config'; import LoggerProxy from '../../../src/logger-proxy'; +// Mock the Worker API +import '../../../__mocks__/workerMock'; + jest.mock('../../../src/logger-proxy', () => ({ __esModule: true, default: { @@ -21,7 +24,8 @@ jest.mock('../../../src/logger-proxy', () => ({ })); jest.mock('../../../src/services/config'); -jest.mock('../../../src/services/core/HttpRequest'); +jest.mock('../../../src/services/core/WebSocket/WebSocketManager'); +jest.mock('../../../src/services/core/WebSocket/connection-service'); jest.mock('../../../src/services/WebCallingService'); jest.mock('../../../src/services'); @@ -33,9 +37,11 @@ jest.mock('../../../src/features/Agentconfig', () => { return jest.fn().mockImplementation(() => mockAgentConfig); }); +global.URL.createObjectURL = jest.fn(() => 'blob:http://localhost:3000/12345'); + describe('webex.cc', () => { let webex; - let mockHttpRequest; + let mockWebSocketManager; beforeEach(() => { webex = new MockWebex({ @@ -50,10 +56,13 @@ describe('webex.cc', () => { once: jest.fn((event, callback) => callback()), }) as unknown as WebexSDK; - mockHttpRequest = { - subscribeNotifications: jest.fn(), + // Instantiate ContactCenter to ensure it's fully initialized + webex.cc = new ContactCenter({ parent: webex }); + + mockWebSocketManager = { + initWebSocket: jest.fn(), }; - webex.cc.httpRequest = mockHttpRequest; + webex.cc.webSocketManager = mockWebSocketManager; // Mock Services instance const mockServicesInstance = { @@ -111,14 +120,14 @@ describe('webex.cc', () => { const connectWebsocketSpy = jest.spyOn(webex.cc, 'connectWebsocket'); mockAgentConfig.getAgentProfile.mockResolvedValue(mockAgentProfile); - mockHttpRequest.subscribeNotifications.mockResolvedValue({ + mockWebSocketManager.initWebSocket.mockResolvedValue({ agentId: 'agent123', }); const result = await webex.cc.register(); expect(connectWebsocketSpy).toHaveBeenCalled(); - expect(mockHttpRequest.subscribeNotifications).toHaveBeenCalledWith({ + expect(mockWebSocketManager.initWebSocket).toHaveBeenCalledWith({ body: { force: true, isKeepAliveEnabled: false, @@ -135,7 +144,7 @@ describe('webex.cc', () => { it('should log error and reject if registration fails', async () => { const mockError = new Error('Error while performing register'); - mockHttpRequest.subscribeNotifications.mockRejectedValue(mockError); + mockWebSocketManager.initWebSocket.mockRejectedValue(mockError); await expect(webex.cc.register()).rejects.toThrow('Error while performing register'); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/agent/index.ts b/packages/@webex/plugin-cc/test/unit/spec/services/agent/index.ts index 522f0c04276..fa713489d78 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/agent/index.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/agent/index.ts @@ -55,4 +55,4 @@ describe('AQM routing agent', () => { expect(req).toBeDefined(); expect(reqSpy).toHaveBeenCalled(); }); -}); +}); \ No newline at end of file diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/HttpRequest.ts b/packages/@webex/plugin-cc/test/unit/spec/services/core/HttpRequest.ts index 48b2cb72ab2..6d268ba2707 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/core/HttpRequest.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/core/HttpRequest.ts @@ -1,9 +1,6 @@ import HttpRequest from '../../../../../src/services/core/HttpRequest'; -import {CC_EVENTS, SubscribeResponse} from '../../../../../src/services/config/types'; -import {WEBSOCKET_EVENT_TIMEOUT} from '../../../../../src/services/constants'; -import {WebexSDK} from '../../../../../src/types'; - -jest.mock('../../../../../src/services/core/WebSocket'); +import {HTTP_METHODS, WebexSDK} from '../../../../../src/types'; +import {IHttpResponse} from '../../../../../src/types'; const mockWebex = { request: jest.fn(), @@ -16,11 +13,6 @@ const mockWebex = { // Cast the request function to a Jest mock function const mockRequest = mockWebex.request as jest.Mock; -const mockWebSocket = { - on: jest.fn(), - connectWebSocket: jest.fn(), -}; - beforeEach(() => { jest.clearAllMocks(); }); @@ -29,47 +21,47 @@ describe('HttpRequest', () => { let httpRequest; beforeEach(() => { httpRequest = HttpRequest.getInstance({webex: mockWebex}); - httpRequest.webSocket = mockWebSocket; }); - describe('subscribeNotifications', () => { - it('should resolve the promise when the Welcome event is received', async () => { - const mockSubscribeResponse = { - body: { - webSocketUrl: 'ws://example.com', - }, - }; - const mockWelcomeEvent = { - type: CC_EVENTS.WELCOME, - data: {message: 'Welcome'}, + describe('request', () => { + it('should send a request and return the response', async () => { + const mockResponse: IHttpResponse = { + statusCode: 200, + body: { message: 'Success' }, + method: 'POST', + url: 'https://example.com/resource', }; - mockRequest.mockResolvedValueOnce(mockSubscribeResponse); + mockRequest.mockResolvedValueOnce(mockResponse); - setTimeout(() => { - httpRequest.eventHandlers.get(CC_EVENTS.WELCOME)(mockWelcomeEvent.data); - }, 100); + const result = await httpRequest.request({ + service: 'service', + resource: 'resource', + method: HTTP_METHODS.POST, + body: { key: 'value' }, + }); - const result = await httpRequest.subscribeNotifications({body: {}}); - expect(result).toEqual(mockWelcomeEvent.data); + expect(result).toEqual(mockResponse); + expect(mockRequest).toHaveBeenCalledWith({ + service: 'service', + resource: 'resource', + method: HTTP_METHODS.POST, + body: { key: 'value' }, + }); }); - it( - 'should reject the promise if the Welcome event is not received within timeout', - async () => { - const mockSubscribeResponse = { - body: { - webSocketUrl: 'ws://example.com', - }, - }; + it('should log and throw an error if the request fails', async () => { + const mockError = new Error('Request failed'); + mockRequest.mockRejectedValueOnce(mockError); - mockRequest.mockResolvedValueOnce(mockSubscribeResponse); - - await expect(httpRequest.subscribeNotifications({body: {}})).rejects.toThrow( - 'Timeout waiting for event' - ); - }, - WEBSOCKET_EVENT_TIMEOUT + 1000 - ); // Increase timeout for this test + await expect( + httpRequest.request({ + service: 'service', + resource: 'resource', + method: HTTP_METHODS.POST, + body: { key: 'value' }, + }) + ).rejects.toThrow('Request failed'); + }); }); -}); +}); \ No newline at end of file diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocket.ts b/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocket.ts deleted file mode 100644 index 2956ce32634..00000000000 --- a/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocket.ts +++ /dev/null @@ -1,133 +0,0 @@ -import MockWebex from '@webex/test-helper-mock-webex'; -import Mercury from '@webex/internal-plugin-mercury'; -import WebSocket from '../../../../../../src/services/core/WebSocket'; - -describe('plugin-cc WebSocket tests', () => { - const webSocketUrl = 'wss://websocket.example.com'; - - describe('WebSocket', () => { - let webex, webSocket; - - beforeEach(() => { - webex = new MockWebex({ - children: { - mercury: Mercury, - }, - logger: { - log: jest.fn(), - error: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - }, - }); - - webSocket = new WebSocket({ - parent: webex, // Ensure the parent is set correctly - }); - webSocket.connect = jest.fn(); - webSocket.disconnect = jest.fn(); - }); - - afterEach(async () => { - if (webSocket.isConnected()) { - await webSocket.disconnectWebSocket(); - } - jest.clearAllMocks(); - }); - - describe('#connectWebSocket', () => { - it('should connect to the websocket', async () => { - const connectSpy = jest.spyOn(webSocket, 'connect'); - await webSocket.connectWebSocket({ - webSocketUrl, - }); - - expect(webSocket.webSocketUrl).toBe(webSocketUrl); - expect(connectSpy).toHaveBeenCalledWith('wss://websocket.example.com'); - connectSpy.mockRestore(); - }); - - it('should return undefined if webSocketUrl is not provided', async () => { - const result = await webSocket.connectWebSocket({ - webSocketUrl: undefined, - }); - - expect(result).toBeUndefined(); - expect(webSocket.webSocketUrl).toBeUndefined(); - }); - }); - - describe('#isConnected', () => { - it('should return the connected status', () => { - webSocket.connected = true; - expect(webSocket.isConnected()).toBe(true); - - webSocket.connected = false; - expect(webSocket.isConnected()).toBe(false); - }); - }); - - describe('#getWebSocketUrl', () => { - it('should return the webSocketUrl', async () => { - webSocket.connect = jest.fn(); - const connectSpy = jest.spyOn(webSocket, 'connect'); - await webSocket.connectWebSocket({ - webSocketUrl, - }); - expect(connectSpy).toHaveBeenCalledWith('wss://websocket.example.com'); - expect(webSocket.getWebSocketUrl()).toBe(webSocketUrl); - }); - - it('should return undefined if webSocketUrl is not set', async () => { - await webSocket.connectWebSocket({ - undefined, - }); - expect(webSocket.getWebSocketUrl()).toBeUndefined(); - }); - }); - - describe('#disconnectWebSocket', () => { - it('should disconnect the websocket and clear related properties', async () => { - webSocket.disconnect = jest.fn(); - - webSocket.disconnect.mockResolvedValue(); - await webSocket.disconnectWebSocket(); - - expect(webSocket.webSocketUrl).toBeUndefined(); - }); - - it('should throw an error if disconnect fails', async () => { - const error = new Error('Disconnect failed'); - webSocket.disconnect = jest.fn().mockRejectedValue(error); - - try { - await webSocket.disconnectWebSocket(); - } catch (err) { - expect(err).toBe(error); - } - }); - }); - - describe('#on and #off', () => { - it('should add and remove event listeners', () => { - const event = 'message'; - const callback = jest.fn(); - - // Add the event listener - webSocket.on(event, callback); - - // Emit the event and check if the callback is called - webSocket.emit(event, 'test data'); - expect(callback).toHaveBeenCalledWith('test data'); - - // Remove the event listener - webSocket.off(event, callback); - - // Emit the event again and check if the callback is not called - callback.mockClear(); // Clear the mock call history - webSocket.emit(event, 'test data'); - expect(callback).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocketManager.ts b/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocketManager.ts new file mode 100644 index 00000000000..bb571acf38a --- /dev/null +++ b/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/WebSocketManager.ts @@ -0,0 +1,198 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { WebSocketManager } from '../../../../../../src/services/core/WebSocket/WebSocketManager'; +import { WebexSDK, SubscribeRequest } from '../../../../../../src/types'; +import { SUBSCRIBE_API, WCC_API_GATEWAY } from '../../../../../../src/services/constants'; +import LoggerProxy from '../../../../../../src/logger-proxy'; + +jest.mock('../../../../../../src/services/core/HttpRequest'); +jest.mock('../../../../../../src/logger-proxy', () => ({ + __esModule: true, + default: { + logger: { + log: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }, + initialize: jest.fn(), + }, +})); + +class MockWebSocket { + static inst: MockWebSocket; + onopen: () => void = () => { }; + onerror: (event: any) => void = () => { }; + onclose: (event: any) => void = () => { }; + onmessage: (msg: any) => void = () => { }; + close = jest.fn(); + send = jest.fn(); + + constructor() { + MockWebSocket.inst = this; + setTimeout(() => { + this.onopen(); + }, 10); + } +} + +// Mock CustomEvent class +class MockCustomEvent extends Event { + detail: T; + + constructor(event: string, params: { detail: T }) { + super(event); + this.detail = params.detail; + } +} + +global.CustomEvent = MockCustomEvent as any; + +describe('WebSocketManager', () => { + let webSocketManager: WebSocketManager; + let mockWebex: WebexSDK; + let mockWorker: any; + + const fakeSubscribeRequest: SubscribeRequest = { + force: true, + isKeepAliveEnabled: false, + clientType: 'WebexCCSDK', + allowMultiLogin: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockWebex = { + request: jest.fn(), + } as unknown as WebexSDK; + + mockWorker = { + postMessage: jest.fn(), + onmessage: jest.fn(), + }; + + global.Worker = jest.fn(() => mockWorker) as any; + global.WebSocket = MockWebSocket as any; + + global.Blob = function (content: any[], options: any) { + return { content, options }; + } as any; + + global.URL.createObjectURL = function (blob: Blob) { + return 'blob:http://localhost:3000/12345'; + }; + + webSocketManager = new WebSocketManager({ webex: mockWebex }); + + setTimeout(() => { + MockWebSocket.inst.onopen(); + MockWebSocket.inst.onmessage({ data: JSON.stringify({ type: "Welcome" }) }); + }, 1); + + console.log = jest.fn(); + console.error = jest.fn(); + }); + + it('should initialize WebSocketManager', () => { + expect(webSocketManager).toBeDefined(); + }); + + it('should register and connect to WebSocket', async () => { + const subscribeResponse = { + body: { + webSocketUrl: 'wss://fake-url', + }, + }; + + (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); + + await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + + expect(mockWebex.request).toHaveBeenCalledWith({ + service: WCC_API_GATEWAY, + resource: SUBSCRIBE_API, + method: 'POST', + body: fakeSubscribeRequest, + }); + }); + + it('should close WebSocket connection', async () => { + const subscribeResponse = { + body: { + webSocketUrl: 'wss://fake-url', + }, + }; + + (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); + + await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + + webSocketManager.close(true, 'Test reason'); + + expect(MockWebSocket.inst.close).toHaveBeenCalled(); + expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'terminate' }); + }); + + it('should handle WebSocket keepalive messages', async () => { + const subscribeResponse = { + body: { + webSocketUrl: 'wss://fake-url', + }, + }; + + (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); + + await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + + setTimeout(() => { + MockWebSocket.inst.onopen(); + MockWebSocket.inst.onmessage({ data: JSON.stringify({ type: 'keepalive' }) }); + mockWorker.onmessage({ + data: { + type: 'keepalive' + } + }); + }, 1); + + expect(MockWebSocket.inst.send).toHaveBeenCalledWith(JSON.stringify({ keepalive: 'true' })); + }); + + it('should handle web socket close and webSocketOnCloseHandler', async () => { + const subscribeResponse = { + body: { + webSocketUrl: 'wss://fake-url', + }, + }; + + (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); + + await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + webSocketManager.shouldReconnect = true; + + // Mock navigator.onLine to simulate network issue + Object.defineProperty(global, 'navigator', { + value: { + onLine: false, + }, + configurable: true, + }); + setTimeout(() => { + MockWebSocket.inst.onclose({ + wasClean: false, + code: 1006, + reason: 'network issue', + target: MockWebSocket.inst, + }); + }, 1); + + // Wait for the close event to be handled + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'terminate' }); + expect(LoggerProxy.logger.info).toHaveBeenCalledWith( + '[WebSocketStatus] | desktop online status is false' + ); + expect(LoggerProxy.logger.error).toHaveBeenCalledWith( + '[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: network issue' + ); + }); +}); \ No newline at end of file diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/connection-service.ts b/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/connection-service.ts new file mode 100644 index 00000000000..b94d889b088 --- /dev/null +++ b/packages/@webex/plugin-cc/test/unit/spec/services/core/WebSocket/connection-service.ts @@ -0,0 +1,62 @@ +import { ConnectionService } from '../../../../../../src/services/core/WebSocket/connection-service'; +import { WebSocketManager } from '../../../../../../src/services/core/WebSocket/WebSocketManager'; +import { SubscribeRequest } from '../../../../../../src/types'; + +jest.mock('../../../../../../src/services/core/WebSocket/WebSocketManager'); + +// Mock CustomEvent class +class MockCustomEvent extends Event { + detail: T; + + constructor(event: string, params: { detail: T }) { + super(event); + this.detail = params.detail; + } +} + +global.CustomEvent = MockCustomEvent as any; + +describe('ConnectionService', () => { + let connectionService: ConnectionService; + let mockWebSocketManager: jest.Mocked; + const mockSubscribeRequest: SubscribeRequest = { + force: true, + isKeepAliveEnabled: false, + clientType: 'WebexCCSDK', + allowMultiLogin: true, + }; + + beforeEach(() => { + mockWebSocketManager = new WebSocketManager({ webex: {} as any }) as jest.Mocked; + + // Mock the addEventListener method + mockWebSocketManager.addEventListener = jest.fn(); + + connectionService = new ConnectionService(mockWebSocketManager, mockSubscribeRequest); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should initialize ConnectionService', () => { + expect(connectionService).toBeDefined(); + }); + + it('should set connection properties', () => { + const newProps = { lostConnectionRecoveryTimeout: 30000 }; + connectionService.setConnectionProp(newProps); + expect(connectionService['connectionProp']).toEqual(newProps); + }); + + it('should handle ping message and update connection data', () => { + const pingMessage = new CustomEvent('message', { detail: JSON.stringify({ keepalive: 'true' }) }); + connectionService['onPing'](pingMessage); + expect(connectionService['isKeepAlive']).toBe(true); + expect(connectionService['isConnectionLost']).toBe(false); + expect(connectionService['isRestoreFailed']).toBe(false); + expect(connectionService['isSocketReconnected']).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/aqm-reqs.ts b/packages/@webex/plugin-cc/test/unit/spec/services/core/aqm-reqs.ts index 321b46cc09c..d601a67c772 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/core/aqm-reqs.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/core/aqm-reqs.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import AqmReqs from '../../../../../src/services/core/aqm-reqs'; import HttpRequest from '../../../../../src/services/core/HttpRequest'; +import {WebSocketManager} from '../../../../../src/services/core/WebSocket/WebSocketManager'; import LoggerProxy from '../../../../../src/logger-proxy'; +import {IHttpResponse} from '../../../../../src/types'; jest.mock('../../../../../src/services/core/HttpRequest'); jest.mock('../../../../../src/logger-proxy', () => ({ @@ -15,33 +17,62 @@ jest.mock('../../../../../src/logger-proxy', () => ({ initialize: jest.fn(), }, })); +jest.mock('../../../../../src/services/core/WebSocket/WebSocketManager'); + +// Mock CustomEvent class +class MockCustomEvent extends Event { + detail: T; + + constructor(event: string, params: {detail: T}) { + super(event); + this.detail = params.detail; + } +} + +global.CustomEvent = MockCustomEvent as any; + +global.window = { + setTimeout: global.setTimeout, +} as any; + const mockHttpRequest = HttpRequest as jest.MockedClass; +const mockWebSocketManager = WebSocketManager as jest.MockedClass; describe('AqmReqs', () => { let httpRequestInstance: jest.Mocked; + let webSocketManagerInstance: jest.Mocked; + const mockHttpRequestResolvedValue: IHttpResponse = { + status: 202, + data: {webSocketUrl: 'fake-url'}, + statusText: 'OK', + headers: {}, + config: {}, + }; + let aqm: AqmReqs; beforeEach(() => { jest.clearAllMocks(); httpRequestInstance = new HttpRequest() as jest.Mocked; mockHttpRequest.getInstance = jest.fn().mockReturnValue(httpRequestInstance); - }); - it('AqmReqs should be defined', async () => { - httpRequestInstance.request.mockResolvedValueOnce({ - status: 202, - data: {webSocketUrl: 'fake-url'}, - statusText: 'OK', - headers: {}, - config: {}, + webSocketManagerInstance = new WebSocketManager({ + webex: {} as any, + }) as jest.Mocked; + + // Mock the addEventListener method + webSocketManagerInstance.addEventListener = jest.fn((event, callback) => { + if (event === 'message') { + webSocketManagerInstance.dispatchEvent = callback; + } }); - const mockWebSocket = { - on: jest.fn(), - }; + aqm = new AqmReqs(webSocketManagerInstance); + mockWebSocketManager.mockImplementation(() => webSocketManagerInstance); + }); - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); + it('AqmReqs should be defined', async () => { + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); - const aqm = new AqmReqs(); const req = aqm.req(() => ({ url: '/url', timeout: 2000, @@ -69,21 +100,8 @@ describe('AqmReqs', () => { }); it('AqmReqs notifcancel', async () => { - httpRequestInstance.request.mockResolvedValueOnce({ - status: 202, - data: {webSocketUrl: 'fake-url'}, - statusText: 'OK', - headers: {}, - config: {}, - }); - - const mockWebSocket = { - on: jest.fn(), - }; - - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); - const aqm = new AqmReqs(); const req = aqm.req(() => ({ url: '/url', timeout: 4000, @@ -121,13 +139,17 @@ describe('AqmReqs', () => { req({}), new Promise((resolve) => { setTimeout(() => { - aqm['onMessage']({ - type: 'RoutingMessage', - data: { - type: 'AgentCtqCancelled', - interactionId: '6920dda3-337a-48b1-b82d-2333392f9905', - }, - }); + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify({ + type: 'RoutingMessage', + data: { + type: 'AgentCtqCancelled', + interactionId: '6920dda3-337a-48b1-b82d-2333392f9905', + }, + }), + }) + ); resolve(); }, 1000); }), @@ -137,21 +159,8 @@ describe('AqmReqs', () => { }); it('AqmReqs notif success', async () => { - httpRequestInstance.request.mockResolvedValueOnce({ - status: 202, - data: {webSocketUrl: 'fake-url'}, - statusText: 'OK', - headers: {}, - config: {}, - }); - - const mockWebSocket = { - on: jest.fn(), - }; - - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); - const aqm = new AqmReqs(); const req = aqm.req(() => ({ url: '/url', timeout: 4000, @@ -189,13 +198,17 @@ describe('AqmReqs', () => { req({}), new Promise((resolve) => { setTimeout(() => { - aqm['onMessage']({ - type: 'RoutingMessage', - data: { - type: 'AgentConsultCreated', - interactionId: '6920dda3-337a-48b1-b82d-2333392f9906', - }, - }); + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify({ + type: 'RoutingMessage', + data: { + type: 'AgentConsultCreated', + interactionId: '6920dda3-337a-48b1-b82d-2333392f9906', + }, + }), + }) + ); resolve(); }, 1000); }), @@ -207,13 +220,6 @@ describe('AqmReqs', () => { it('AqmReqs notif success with async error', async () => { httpRequestInstance.request.mockRejectedValueOnce(new Error('Async error')); - const mockWebSocket = { - on: jest.fn(), - }; - - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); - - const aqm = new AqmReqs(); const req = aqm.req(() => ({ url: '/url', timeout: 4000, @@ -254,21 +260,8 @@ describe('AqmReqs', () => { }); it('AqmReqs notif fail', async () => { - httpRequestInstance.request.mockResolvedValueOnce({ - status: 202, - data: {webSocketUrl: 'fake-url'}, - statusText: 'OK', - headers: {}, - config: {}, - }); - - const mockWebSocket = { - on: jest.fn(), - }; + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); - - const aqm = new AqmReqs(); const req = aqm.req(() => ({ url: '/url', timeout: 4000, @@ -306,13 +299,17 @@ describe('AqmReqs', () => { req({}), new Promise((resolve) => { setTimeout(() => { - aqm['onMessage']({ - type: 'RoutingMessage', - data: { - type: 'AgentConsultFailed', - interactionId: '6920dda3-337a-48b1-b82d-2333392f9907', - }, - }); + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify({ + type: 'RoutingMessage', + data: { + type: 'AgentConsultFailed', + interactionId: '6920dda3-337a-48b1-b82d-2333392f9907', + }, + }), + }) + ); resolve(); }, 1000); }), @@ -321,61 +318,224 @@ describe('AqmReqs', () => { } catch (e) {} }); - it('should handle onMessage with Welcome event', () => { - const mockWebSocket = { - on: jest.fn(), - }; - - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); - - const aqm = new AqmReqs(); - - const event = { - type: 'Welcome', - }; - - aqm['onMessage'](event); - - expect(LoggerProxy.logger.info).toHaveBeenCalledWith( - 'Welcome message from Notifs Websocket[object Object]' - ); - }); - - it('should handle onMessage with Keepalive event', () => { - const mockWebSocket = { - on: jest.fn(), - }; - - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); - - const aqm = new AqmReqs(); + describe('Event tests', () => { + it('should handle onMessage events', async () => { + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); + + const req = aqm.req(() => ({ + url: '/url', + timeout: 2000, + notifSuccess: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultConferenced', interactionId: 'intrid'}, + }, + msg: {}, + }, + notifFail: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultConferenceFailed'}, + }, + errId: 'Service.aqm.contact.consult', + }, + })); + + try { + await req({}); + } catch (e) { + expect(e).toBeDefined(); + } + + // Welcome event + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify({ + type: 'Welcome', + data: {type: 'WelcomeEvent'}, + }), + }) + ); + + expect(LoggerProxy.logger.info).toHaveBeenCalledWith('Welcome message from Notifs Websocket'); + + // Keep-alive events + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify({ + keepalive: 'true', + data: {type: 'KeepaliveEvent'}, + }), + }) + ); + + expect(LoggerProxy.logger.info).toHaveBeenCalledWith('Keepalive from web socket'); + + // Unhandled event + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify({ + type: 'UnhandledMessage', + data: {type: 'UnhandledEvent'}, + }), + }) + ); + + expect(LoggerProxy.logger.info).toHaveBeenCalledWith( + 'event=missingEventHandler | [AqmReqs] missing routing message handler' + ); + }); - const event = { - keepalive: true, - }; + it('should correctly print bind object', () => { + const bind = { + type: 'RoutingMessage', + data: { + type: 'AgentConsultCreated', + interactionId: 'intrid', + }, + }; + const result = aqm['bindPrint'](bind); + expect(result).toBe( + 'type=RoutingMessage,data=(type=AgentConsultCreated,interactionId=intrid)' + ); + }); - aqm['onMessage'](event); + it('should correctly check bind object', () => { + const bind = { + type: 'RoutingMessage', + data: { + type: 'AgentConsultCreated', + interactionId: 'intrid', + }, + }; + const msg = { + type: 'RoutingMessage', + data: { + type: 'AgentConsultCreated', + interactionId: 'intrid', + }, + }; + const result = aqm['bindCheck'](bind, msg); + expect(result).toBe(true); + }); - expect(LoggerProxy.logger.info).toHaveBeenCalledWith('Keepalive from notifs[object Object]'); - }); + it('should handle reqEmpty', async () => { + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); - it('should handle onMessage with missing event handler', () => { - const mockWebSocket = { - on: jest.fn(), - }; + const reqEmpty = aqm.reqEmpty(() => ({ + url: '/url', + timeout: 2000, + notifSuccess: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultConferenced', interactionId: 'intrid'}, + }, + msg: {}, + }, + notifFail: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultConferenceFailed'}, + }, + errId: 'Service.aqm.contact.consult', + }, + })); - httpRequestInstance.getWebSocket = jest.fn().mockReturnValue(mockWebSocket); + try { + await reqEmpty(); + } catch (e) { + expect(e).toBeDefined(); + } + }); - const aqm = new AqmReqs(); + it('should handle failed request with err function', async () => { + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); + + const conf = { + host: 'fake-host', + url: '/url', + method: 'POST', + data: {}, + notifSuccess: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultCreated', interactionId: 'intrid'}, + }, + }, + notifFail: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultFailed'}, + }, + err: (msg: any) => new Error('Custom error'), + }, + }; - const event = { - type: 'UnknownEvent', - }; + const promise = aqm['createPromise'](conf); + global.setTimeout(() => { + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify({ + type: 'RoutingMessage', + data: { + type: 'AgentConsultFailed', + interactionId: 'intrid', + }, + }), + }) + ); + }, 0); - aqm['onMessage'](event); + await expect(promise).rejects.toThrow('Custom error'); + }); - expect(LoggerProxy.logger.info).toHaveBeenCalledWith( - 'event=missingEventHandler | [AqmReqs] missing routing message handler[object Object]' - ); + it('should handle request with notifCancel', async () => { + httpRequestInstance.request.mockResolvedValueOnce(mockHttpRequestResolvedValue); + + const conf = { + host: 'fake-host', + url: '/url', + method: 'POST', + data: {}, + notifSuccess: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultCreated', interactionId: 'intrid'}, + }, + }, + notifFail: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentConsultFailed'}, + }, + errId: 'Service.aqm.contact.consult', + }, + notifCancel: { + bind: { + type: 'RoutingMessage', + data: {type: 'AgentCtqCancelled', interactionId: 'intrid'}, + }, + }, + }; + + const promise = aqm['createPromise'](conf); + const eventData = { + type: 'RoutingMessage', + data: { + type: 'AgentCtqCancelled', + interactionId: 'intrid', + }, + }; + global.setTimeout(() => { + webSocketManagerInstance.dispatchEvent( + new CustomEvent('message', { + detail: JSON.stringify(eventData), + }) + ); + }, 0); + + const result = await promise; + expect(result).toEqual(eventData); + }); }); });