testplane
Version:
Tests framework based on mocha and wdio
425 lines • 19.8 kB
JavaScript
;
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