UNPKG

testplane

Version:

Tests framework based on mocha and wdio

425 lines 19.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CDPConnection = void 0; const ws_1 = require("ws"); const debug_1 = require("./debug"); const ws_endpoint_1 = require("./ws-endpoint"); const utils_1 = require("./utils"); const error_1 = require("./error"); const constants_1 = require("./constants"); var WsConnectionStatus; (function (WsConnectionStatus) { WsConnectionStatus[WsConnectionStatus["DISCONNECTED"] = 0] = "DISCONNECTED"; WsConnectionStatus[WsConnectionStatus["CONNECTING"] = 1] = "CONNECTING"; WsConnectionStatus[WsConnectionStatus["CONNECTED"] = 2] = "CONNECTED"; WsConnectionStatus[WsConnectionStatus["CLOSED"] = 3] = "CLOSED"; })(WsConnectionStatus || (WsConnectionStatus = {})); // Closing WS when its still not connected produces error: // https://github.com/websockets/ws/blob/86eac5b44ac2bff9087ec40c9bd06bc7b4f0da07/lib/websocket.js#L297-L301 const closeWsConnection = (ws) => { if (ws.readyState !== ws.CONNECTING) { ws.close(); } else { ws.once("open", () => { ws.close(); }); } }; class CDPConnection { constructor(cdpWsEndpoint, headers) { this.onEventMessage = null; this._onPong = null; this._pingInterval = null; this._pingSubsequentFails = 0; this._onConnectionCloseFn = null; // Defined, if there is connection attempt at the moment this._wsConnectionStatus = WsConnectionStatus.DISCONNECTED; this._wsConnection = null; this._wsConnectionPromise = null; this._requestId = 0; this._pendingRequests = {}; this._cdpWsEndpoint = cdpWsEndpoint; this._headers = headers; } /** @description Creates CDPConnection without establishing it */ static async create(browser) { const sessionId = browser.publicAPI.sessionId; const cdpWsEndpoint = await (0, ws_endpoint_1.getWsEndpoint)(browser); const headers = browser.publicAPI.options?.headers ?? {}; if (!cdpWsEndpoint) { throw new error_1.CDPError({ message: `Couldn't determine CDP endpoint for session ${sessionId}` }); } return new this(cdpWsEndpoint, headers); } /** @description Tries to establish ws connection with timeout */ async _tryToEstablishWsConnection(endpoint) { return new Promise(resolve => { try { const onConnectionCloseFn = () => done(new error_1.CDPConnectionTerminatedError()); if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { onConnectionCloseFn(); } else { this._onConnectionCloseFn = onConnectionCloseFn; } // eslint-disable-next-line const cdpConnectionInstance = this; const ws = new ws_1.WebSocket(endpoint, { headers: this._headers }); let isSettled = false; const timeoutId = setTimeout(() => { closeWsConnection(ws); done(new error_1.CDPTimeoutError({ message: `Couldn't establish CDP connection to "${endpoint}" in ${constants_1.CDP_CONNECTION_TIMEOUT}ms`, })); }, constants_1.CDP_CONNECTION_TIMEOUT).unref(); const onOpen = () => { done(ws); }; const onError = (error) => { closeWsConnection(ws); done(new error_1.CDPError({ message: `Couldn't establish CDP connection to "${endpoint}": ${error}`, })); }; const onClose = () => { done(new error_1.CDPError({ message: `CDP connection to "${endpoint}" unexpectedly closed while establishing`, })); }; ws.on("open", onOpen); ws.on("error", onError); ws.on("close", onClose); // eslint-disable-next-line no-inner-declarations function done(result) { if (isSettled) { return; } cdpConnectionInstance._onConnectionCloseFn = null; isSettled = true; clearTimeout(timeoutId); ws.off("open", onOpen); ws.off("error", onError); ws.off("close", onClose); resolve(result); } } catch (err) { resolve(err); } }); } /** * @description creates ws connection with retries or returns existing one * @note Concurrent requests with same params produce same ws connection */ async _getWsConnection() { const ws = this._wsConnection; if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { throw new error_1.CDPConnectionTerminatedError({ message: `Session to ${this._cdpWsEndpoint} was closed` }); } if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING && this._wsConnectionPromise) { return this._wsConnectionPromise; } if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState === ws.OPEN) { return ws; } if (this._wsConnectionStatus === WsConnectionStatus.CONNECTED && ws && ws.readyState !== ws.OPEN) { this._closeWsConnection("CDP connection was in invalid state", WsConnectionStatus.DISCONNECTED); } this._wsConnectionStatus = WsConnectionStatus.CONNECTING; this._wsConnectionPromise = (async () => { try { for (let retriesLeft = constants_1.CDP_CONNECTION_RETRIES; retriesLeft >= 0; retriesLeft--) { const result = await this._tryToEstablishWsConnection(this._cdpWsEndpoint); if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { if (result instanceof ws_1.WebSocket) { closeWsConnection(result); } throw new error_1.CDPConnectionTerminatedError(); } if (result instanceof ws_1.WebSocket) { (0, debug_1.debugCdp)(`Established CDP connection to ${this._cdpWsEndpoint}`); this._wsConnection = result; this._wsConnectionStatus = WsConnectionStatus.CONNECTED; this._pingHealthCheckStart(); const onPing = () => result.pong(); const onMessage = (data) => this._onMessage(data); const onError = (err) => { if (result === this._wsConnection) { this._closeWsConnection(`An error occured in CDP connection: ${err}`, WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } }; result.on("ping", onPing); result.on("message", onMessage); result.on("error", onError); result.once("close", () => { result.off("ping", onPing); result.off("message", onMessage); result.off("error", onError); if (result === this._wsConnection) { this._closeWsConnection("CDP connection was closed unexpectedly", WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } }); return result; } if (!(result instanceof error_1.CDPError) || result instanceof error_1.CDPConnectionTerminatedError) { throw result; } (0, debug_1.debugCdp)(`${result.message}; retries left: ${retriesLeft}`); // Intentionally avoiding wait after timeout if (result instanceof error_1.CDPError && !(result instanceof error_1.CDPTimeoutError)) { await (0, utils_1.exponentiallyWait)({ baseDelay: constants_1.CDP_CONNECTION_RETRY_BASE_DELAY, attempt: constants_1.CDP_CONNECTION_RETRIES - retriesLeft, }); } } throw new error_1.CDPError({ message: `Couldn't establish CDP connection to ${this._cdpWsEndpoint} in ${constants_1.CDP_CONNECTION_RETRIES} retries`, }); } catch (err) { if (this._wsConnectionStatus === WsConnectionStatus.CONNECTING) { this._wsConnectionStatus = WsConnectionStatus.DISCONNECTED; this._wsConnectionPromise = null; } throw err; } finally { if (this._wsConnectionStatus !== WsConnectionStatus.CONNECTING) { this._wsConnectionPromise = null; } } })(); return this._wsConnectionPromise; } /** @description Handles websocket incoming messages, resolving pending requests */ _onMessage(data) { const message = data.toString("utf8"); (0, debug_1.debugCdp)(`< ${message}`); try { const jsonParsedMessage = JSON.parse(message); if (!("id" in jsonParsedMessage)) { if (this.onEventMessage) { this.onEventMessage(jsonParsedMessage); } return; } const requestId = jsonParsedMessage.id; if (!this._pendingRequests[requestId]) { (0, debug_1.debugCdp)(`Received response to request ${requestId}, which is probably timed out already`); return; } if ("result" in jsonParsedMessage) { this._pendingRequests[requestId](jsonParsedMessage.result); } else if ("error" in jsonParsedMessage) { this._pendingRequests[requestId](new error_1.CDPError({ message: jsonParsedMessage.error.message, code: jsonParsedMessage.error.code, requestId: requestId, })); } else { this._pendingRequests[requestId](new error_1.CDPError({ message: "Received malformed response without result", code: constants_1.CDP_ERROR_CODE.MALFORMED_RESPONSE, requestId: requestId, })); } } catch (err) { (0, debug_1.debugCdp)(`Couldn't process CDP message.\n\tError: ${err}\n\tMessage: "${message}"`); const requestId = (0, utils_1.extractRequestIdFromBrokenResponse)(message); if (requestId && this._pendingRequests[requestId]) { this._pendingRequests[requestId](new error_1.CDPError({ message: "Received malformed response: response is invalid JSON", code: constants_1.CDP_ERROR_CODE.MALFORMED_RESPONSE, requestId: requestId, })); } } } /** * @description Produces connection-"uniq" request ids * @note Theoretically, it can collide, but given "CDP_MAX_REQUEST_ID" is INT32_MAX, it wont */ _getRequestId() { const id = ++this._requestId; if (this._requestId >= constants_1.CDP_MAX_REQUEST_ID) { this._requestId = 0; } return id; } /** @description establishes ws connection, sends request with timeout and waits for response */ async _tryToSendRequest(method, { params, sessionId }) { const id = this._getRequestId(); const ws = await this._getWsConnection(); const requestMessage = JSON.stringify({ id, sessionId, method, params }); if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { throw new error_1.CDPConnectionTerminatedError({ message: `Couldn't send "${requestMessage}" because CDP connection was manually closed`, }); } (0, debug_1.debugCdp)(`> ${requestMessage}`); return new Promise(resolve => { const pendingRequests = this._pendingRequests; let isSettled = false; const onTimeout = setTimeout(() => { const err = new error_1.CDPTimeoutError({ message: `Timed out while waiting for ${method} for ${constants_1.CDP_REQUEST_TIMEOUT}ms`, requestId: id, }); done(err); }, constants_1.CDP_REQUEST_TIMEOUT).unref(); function done(response) { if (isSettled) { return; } isSettled = true; delete pendingRequests[id]; clearTimeout(onTimeout); resolve(response); } pendingRequests[id] = done; ws.send(requestMessage, error => { if (!error) { return; } done(new error_1.CDPError({ message: `Couldn't send CDP request "${method}": ${error.message}`, code: constants_1.CDP_ERROR_CODE.SEND_FAILED, requestId: id, })); // Proactively closing connection as "send error" is marker that something bad with connection happened if (ws === this._wsConnection) { this._closeWsConnection("CDP connection was considered broken as 'send' failed", WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } }); }); } /** @description Performs high-level CDP request with retries and timeouts */ async request(method, { params, sessionId } = {}) { let result; for (let retriesLeft = constants_1.CDP_REQUEST_RETRIES; retriesLeft >= 0; retriesLeft--) { result = (await this._tryToSendRequest(method, { params, sessionId })); const noRetriesLeft = retriesLeft <= 0; const connectionIsClosed = this._wsConnectionStatus === WsConnectionStatus.CLOSED; if (!(result instanceof error_1.CDPError) || result.isNonRetryable() || noRetriesLeft || connectionIsClosed) { break; } (0, debug_1.debugCdp)(`${result.message}; retries left: ${retriesLeft}`); // Intentionally avoiding wait after timeout if (result instanceof error_1.CDPError && !(result instanceof error_1.CDPTimeoutError)) { await (0, utils_1.exponentiallyWait)({ baseDelay: constants_1.CDP_REQUEST_RETRY_BASE_DELAY, attempt: constants_1.CDP_REQUEST_RETRIES - retriesLeft, }); } } if (result instanceof Error) { throw result; } return result; } _closeWsConnection(sessionAbortMessage, status) { const ws = this._wsConnection; if (!ws || this._wsConnectionStatus === WsConnectionStatus.CLOSED) { this._wsConnection = null; return; } (0, debug_1.debugCdp)(`${sessionAbortMessage}; endpoint: "${this._cdpWsEndpoint}"`); if (status === WsConnectionStatus.CLOSED && this._onConnectionCloseFn) { this._onConnectionCloseFn(); } this._wsConnection = null; this._wsConnectionStatus = status; this._abortPendingRequests(`Request was aborted because ${sessionAbortMessage}`); this._pingHealthCheckStop(); closeWsConnection(ws); } /** * @description Tries to re-establish connection after network drops * @note Silently gives up after failed "CDP_CONNECTION_RETRIES" attempts */ _tryToReconnect() { (0, debug_1.debugCdp)(`Trying to reconnect; endpoint: "${this._cdpWsEndpoint}"`); this._getWsConnection() .then(() => (0, debug_1.debugCdp)(`Successfully reconnected to session; endpoint: "${this._cdpWsEndpoint}"`)) .catch(() => (0, debug_1.debugCdp)(`Couldn't reconnect to session automatically; endpoint: "${this._cdpWsEndpoint}"`)); } /** @description Used to abort all pending requests when connection is closed */ _abortPendingRequests(message) { const pendingRequests = this._pendingRequests; const pendingRequestIds = Object.keys(pendingRequests).map(Number); this._pendingRequests = {}; for (const requestId of pendingRequestIds) { if (pendingRequests[requestId]) { pendingRequests[requestId](new error_1.CDPConnectionTerminatedError({ message, requestId, })); } } } /** @description Closes websocket connection, terminating all pending requests */ close() { this._closeWsConnection("Connection was closed manually", WsConnectionStatus.CLOSED); } _pingHealthCheckStop() { this._pingSubsequentFails = 0; if (this._pingInterval) { clearInterval(this._pingInterval); } if (this._wsConnection && this._onPong) { this._wsConnection.off("pong", this._onPong); } } _isWebSocketActive(ws) { return Boolean(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING && ws === this._wsConnection); } _pingHealthCheckStart() { this._pingHealthCheckStop(); const ws = this._wsConnection; if (!ws || !this._isWebSocketActive(ws)) { return; } this._pingHealthCheckStop(); let isWaitingForPong = false; let pongTimeout; const onPong = (this._onPong = () => { if (isWaitingForPong && this._isWebSocketActive(ws)) { isWaitingForPong = false; (0, debug_1.debugCdp)("< PONG"); clearTimeout(pongTimeout); this._pingSubsequentFails = 0; } }); ws.on("pong", onPong); const pingInterval = (this._pingInterval = setInterval(() => { if (!this._isWebSocketActive(ws)) { clearInterval(pingInterval); return; } pongTimeout = setTimeout(() => { if (isWaitingForPong && this._isWebSocketActive(ws)) { isWaitingForPong = false; this._pingSubsequentFails++; (0, debug_1.debugCdp)(`Ping failed(${this._pingSubsequentFails} in a row) in ${constants_1.CDP_PING_TIMEOUT}ms`); if (this._pingSubsequentFails >= constants_1.CDP_PING_MAX_SUBSEQUENT_FAILS) { this._closeWsConnection(`CDP connection was considered broken as ${this._pingSubsequentFails} pings failed in a row`, WsConnectionStatus.DISCONNECTED); this._tryToReconnect(); } } }, constants_1.CDP_PING_TIMEOUT).unref(); ws.ping(); (0, debug_1.debugCdp)("> PING"); isWaitingForPong = true; }, constants_1.CDP_PING_INTERVAL).unref()); } } exports.CDPConnection = CDPConnection; //# sourceMappingURL=connection.js.map