diff --git a/package-lock.json b/package-lock.json index 7d09a3f75..3d758a143 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "vite": "5.1.6", "wait-port": "1.1.0", "worker-farm": "1.7.0", + "ws": "8.18.3", "yallist": "3.1.1" }, "bin": { diff --git a/package.json b/package.json index 9fba4bd6f..cc4f5fbe9 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "vite": "5.1.6", "wait-port": "1.1.0", "worker-farm": "1.7.0", + "ws": "8.18.3", "yallist": "3.1.1" }, "devDependencies": { diff --git a/src/browser/cdp/connection.ts b/src/browser/cdp/connection.ts new file mode 100644 index 000000000..d446e6e8b --- /dev/null +++ b/src/browser/cdp/connection.ts @@ -0,0 +1,561 @@ +import { WebSocket, type RawData } from "ws"; +import { debugCdp } from "./debug"; +import { getWsEndpoint } from "./ws-endpoint"; +import { exponentiallyWait, extractRequestIdFromBrokenResponse } from "./utils"; +import { CDPConnectionTerminatedError, CDPError, CDPTimeoutError } from "./error"; +import { + CDP_CONNECTION_RETRIES, + CDP_CONNECTION_RETRY_BASE_DELAY, + CDP_CONNECTION_TIMEOUT, + CDP_MAX_REQUEST_ID, + CDP_REQUEST_RETRIES, + CDP_REQUEST_RETRY_BASE_DELAY, + CDP_REQUEST_TIMEOUT, + CDP_ERROR_CODE, + CDP_PING_INTERVAL, + CDP_PING_TIMEOUT, + CDP_PING_MAX_SUBSEQUENT_FAILS, +} from "./constants"; +import type { CDPEvent, CDPMessage, CDPRequest } from "./types"; +import type { Browser } from "../types"; + +enum WsConnectionStatus { + DISCONNECTED, // Not connected, able to connect + CONNECTING, // Connection is being established + CONNECTED, // Connection established + CLOSED, // Connection is disposed and does not require reconnecting +} + +type OnEventMessageFn = (cdpEventMessage: CDPEvent) => unknown; + +export class CDPConnection { + public onEventMessage: OnEventMessageFn | null = null; + private readonly _cdpWsEndpoint: string; + private readonly _headers?: Record; + private _onPong: (() => void) | null = null; + private _pingInterval: ReturnType | null = null; + private _pingSubsequentFails = 0; + private _onConnectionCloseFn: (() => void) | null = null; // Defined, if there is connection attempt at the moment + private _wsConnectionStatus: WsConnectionStatus = WsConnectionStatus.DISCONNECTED; + private _wsConnection: WebSocket | null = null; + private _wsConnectionPromise: Promise | null = null; + private _requestId = 0; + private _pendingRequests: Record | CDPError) => void> = {}; + + private constructor(cdpWsEndpoint: string, headers?: Record) { + this._cdpWsEndpoint = cdpWsEndpoint; + this._headers = headers; + } + + /** @description Creates CDPConnection without establishing it */ + static async create(browser: Browser): Promise { + const sessionId = browser.publicAPI.sessionId; + + const cdpWsEndpoint = await getWsEndpoint(browser); + const headers = browser.publicAPI.options.headers; + + if (!cdpWsEndpoint) { + throw new CDPError({ message: `Couldn't determine CDP endpoint for session ${sessionId}` }); + } + + return new this(cdpWsEndpoint, headers); + } + + /** @description Tries to establish ws connection with timeout */ + private async _tryToEstablishWsConnection(endpoint: string): Promise { + return new Promise(resolve => { + try { + const onConnectionCloseFn = (): void => done(new CDPConnectionTerminatedError()); + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + onConnectionCloseFn(); + } else { + this._onConnectionCloseFn = onConnectionCloseFn; + } + + // eslint-disable-next-line + const cdpConnectionInstance = this; + const ws = new WebSocket(endpoint, { headers: this._headers }); + + let isSettled = false; + + const timeoutId = setTimeout(() => { + ws.close(); + done( + new CDPTimeoutError({ + message: `Couldn't establish CDP connection to "${endpoint}" in ${CDP_CONNECTION_TIMEOUT}ms`, + }), + ); + }, CDP_CONNECTION_TIMEOUT).unref(); + + const onOpen = (): void => { + done(ws); + }; + + const onError = (error: unknown): void => { + ws.close(); + done( + new CDPError({ + message: `Couldn't establish CDP connection to "${endpoint}": ${error}`, + }), + ); + }; + + const onClose = (): void => { + done( + new 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: WebSocket | Error): void { + 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 as Error); + } + }); + } + + /** + * @description creates ws connection with retries or returns existing one + * @note Concurrent requests with same params produce same ws connection + */ + private async _getWsConnection(): Promise { + const ws = this._wsConnection; + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + throw new 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 (): Promise => { + try { + for (let retriesLeft = CDP_CONNECTION_RETRIES; retriesLeft >= 0; retriesLeft--) { + const result = await this._tryToEstablishWsConnection(this._cdpWsEndpoint); + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + if (result instanceof WebSocket) { + result.close(); + } + throw new CDPConnectionTerminatedError(); + } + + if (result instanceof WebSocket) { + debugCdp(`Established CDP connection to ${this._cdpWsEndpoint}`); + + this._wsConnection = result; + this._wsConnectionStatus = WsConnectionStatus.CONNECTED; + this._pingHealthCheckStart(); + + const onPing = (): void => result.pong(); + const _onMessage = (data: RawData): void => this._onMessage(data); + const onError = (err: Error): void => { + 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 CDPError) || result instanceof CDPConnectionTerminatedError) { + throw result; + } + + debugCdp(`${result.message}; retries left: ${retriesLeft}`); + + // Intentionally avoiding wait after timeout + if (result instanceof CDPError && !(result instanceof CDPTimeoutError)) { + await exponentiallyWait({ + baseDelay: CDP_CONNECTION_RETRY_BASE_DELAY, + attempt: CDP_CONNECTION_RETRIES - retriesLeft, + }); + } + } + + throw new CDPError({ + message: `Couldn't establish CDP connection to ${this._cdpWsEndpoint} in ${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 */ + private _onMessage(data: RawData): void { + const message = data.toString("utf8"); + + debugCdp(`< ${message}`); + + try { + const jsonParsedMessage: CDPMessage = JSON.parse(message); + + if (!("id" in jsonParsedMessage)) { + if (this.onEventMessage) { + this.onEventMessage(jsonParsedMessage); + } + + return; + } + + const requestId = jsonParsedMessage.id; + + if (!this._pendingRequests[requestId]) { + 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 CDPError({ + message: jsonParsedMessage.error.message, + code: jsonParsedMessage.error.code, + requestId: requestId, + }), + ); + } else { + this._pendingRequests[requestId]( + new CDPError({ + message: "Received malformed response without result", + code: CDP_ERROR_CODE.MALFORMED_RESPONSE, + requestId: requestId, + }), + ); + } + } catch (err) { + debugCdp(`Couldn't process CDP message.\n\tError: ${err}\n\tMessage: "${message}"`); + + const requestId = extractRequestIdFromBrokenResponse(message); + + if (requestId && this._pendingRequests[requestId]) { + this._pendingRequests[requestId]( + new CDPError({ + message: "Received malformed response: response is invalid JSON", + code: 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 + */ + private _getRequestId(): number { + const id = ++this._requestId; + + if (this._requestId >= CDP_MAX_REQUEST_ID) { + this._requestId = 0; + } + + return id; + } + + /** @description establishes ws connection, sends request with timeout and waits for response */ + private async _tryToSendRequest( + method: CDPRequest["method"], + { params, sessionId }: Omit, + ): Promise | CDPError> { + const id = this._getRequestId(); + const ws = await this._getWsConnection(); + const requestMessage = JSON.stringify({ id, sessionId, method, params }); + + if (this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + throw new CDPConnectionTerminatedError({ + message: `Couldn't send "${requestMessage}" because CDP connection was manually closed`, + }); + } + + debugCdp(`> ${requestMessage}`); + + return new Promise | CDPError>(resolve => { + const pendingRequests = this._pendingRequests; + + let isSettled = false; + + const onTimeout = setTimeout(() => { + const err = new CDPTimeoutError({ + message: `Timed out while waiting for ${method} for ${CDP_REQUEST_TIMEOUT}ms`, + requestId: id, + }); + + done(err); + }, CDP_REQUEST_TIMEOUT).unref(); + + function done(response: Record | CDPError): void { + if (isSettled) { + return; + } + + isSettled = true; + delete pendingRequests[id]; + clearTimeout(onTimeout); + resolve(response); + } + + pendingRequests[id] = done; + + ws.send(requestMessage, error => { + if (!error) { + return; + } + + done( + new CDPError({ + message: `Couldn't send CDP request "${method}": ${error.message}`, + code: 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: CDPRequest["method"], + { params, sessionId }: Omit = {}, + ): Promise { + let result!: T | Error; + + for (let retriesLeft = CDP_REQUEST_RETRIES; retriesLeft >= 0; retriesLeft--) { + result = (await this._tryToSendRequest(method, { params, sessionId })) as T | Error; + + const noRetriesLeft = retriesLeft <= 0; + const connectionIsClosed = this._wsConnectionStatus === WsConnectionStatus.CLOSED; + + if (!(result instanceof CDPError) || result.isNonRetryable() || noRetriesLeft || connectionIsClosed) { + break; + } + + debugCdp(`${result.message}; retries left: ${retriesLeft}`); + + // Intentionally avoiding wait after timeout + if (result instanceof CDPError && !(result instanceof CDPTimeoutError)) { + await exponentiallyWait({ + baseDelay: CDP_REQUEST_RETRY_BASE_DELAY, + attempt: CDP_REQUEST_RETRIES - retriesLeft, + }); + } + } + + if (result instanceof Error) { + throw result; + } + + return result; + } + + private _closeWsConnection( + sessionAbortMessage: string, + status: WsConnectionStatus.CLOSED | WsConnectionStatus.DISCONNECTED, + ): void { + const ws = this._wsConnection; + + if (!ws || this._wsConnectionStatus === WsConnectionStatus.CLOSED) { + this._wsConnection = null; + return; + } + + 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(); + + ws.close(); + } + + /** + * @description Tries to re-establish connection after network drops + * @note Silently gives up after failed "CDP_CONNECTION_RETRIES" attempts + */ + private _tryToReconnect(): void { + debugCdp(`Trying to reconnect; endpoint: "${this._cdpWsEndpoint}"`); + + this._getWsConnection() + .then(() => debugCdp(`Successfully reconnected to session; endpoint: "${this._cdpWsEndpoint}"`)) + .catch(() => debugCdp(`Couldn't reconnect to session automatically; endpoint: "${this._cdpWsEndpoint}"`)); + } + + /** @description Used to abort all pending requests when connection is closed */ + private _abortPendingRequests(message: string): void { + const pendingRequests = this._pendingRequests; + const pendingRequestIds = Object.keys(pendingRequests).map(Number); + + this._pendingRequests = {}; + + for (const requestId of pendingRequestIds) { + if (pendingRequests[requestId]) { + pendingRequests[requestId]( + new CDPConnectionTerminatedError({ + message, + requestId, + }), + ); + } + } + } + + /** @description Closes websocket connection, terminating all pending requests */ + close(): void { + this._closeWsConnection("Connection was closed manually", WsConnectionStatus.CLOSED); + } + + private _pingHealthCheckStop(): void { + this._pingSubsequentFails = 0; + + if (this._pingInterval) { + clearInterval(this._pingInterval); + } + + if (this._wsConnection && this._onPong) { + this._wsConnection.off("pong", this._onPong); + } + } + + private _isWebSocketActive(ws: WebSocket): boolean { + return Boolean(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING && ws === this._wsConnection); + } + + private _pingHealthCheckStart(): void { + this._pingHealthCheckStop(); + + const ws = this._wsConnection; + + if (!ws || !this._isWebSocketActive(ws)) { + return; + } + + this._pingHealthCheckStop(); + + let isWaitingForPong = false; + let pongTimeout: ReturnType; + + const onPong = (this._onPong = (): void => { + if (isWaitingForPong && this._isWebSocketActive(ws)) { + isWaitingForPong = false; + + 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++; + + debugCdp(`Ping failed(${this._pingSubsequentFails} in a row) in ${CDP_PING_TIMEOUT}ms`); + + if (this._pingSubsequentFails >= CDP_PING_MAX_SUBSEQUENT_FAILS) { + this._closeWsConnection( + `CDP connection was considered broken as ${this._pingSubsequentFails} pings failed in a row`, + WsConnectionStatus.DISCONNECTED, + ); + this._tryToReconnect(); + } + } + }, CDP_PING_TIMEOUT).unref(); + + ws.ping(); + + debugCdp("> PING"); + + isWaitingForPong = true; + }, CDP_PING_INTERVAL).unref()); + } +} diff --git a/src/browser/cdp/constants.ts b/src/browser/cdp/constants.ts new file mode 100644 index 000000000..9a8862841 --- /dev/null +++ b/src/browser/cdp/constants.ts @@ -0,0 +1,16 @@ +export const CDP_CONNECTION_TIMEOUT = 15000; // 15 sec +export const CDP_CONNECTION_RETRIES = 3; +export const CDP_CONNECTION_RETRY_BASE_DELAY = 500; +export const CDP_REQUEST_TIMEOUT = 15000; // 15 sec +export const CDP_REQUEST_RETRIES = 3; +export const CDP_REQUEST_RETRY_BASE_DELAY = 500; +export const CDP_MAX_REQUEST_ID = 2147483647; // INT32_MAX +export const CDP_PING_INTERVAL = 15000; // 15 sec +export const CDP_PING_TIMEOUT = 10000; // 10 sec +export const CDP_PING_MAX_SUBSEQUENT_FAILS = 2; +export const CDP_ERROR_CODE = { + MALFORMED_RESPONSE: -32810, // Custom error code + SEND_FAILED: -32820, // Custom error code + TIMEOUT: -32830, // Custom error code + CONNECTION_TERMINATED: -32840, // Custom error code +} as const; diff --git a/src/browser/cdp/debug.ts b/src/browser/cdp/debug.ts new file mode 100644 index 000000000..25969fc02 --- /dev/null +++ b/src/browser/cdp/debug.ts @@ -0,0 +1,3 @@ +import debugModule from "debug"; + +export const debugCdp = debugModule("testplane:cdp"); diff --git a/src/browser/cdp/emitter.ts b/src/browser/cdp/emitter.ts new file mode 100644 index 000000000..3dbcf04f9 --- /dev/null +++ b/src/browser/cdp/emitter.ts @@ -0,0 +1,23 @@ +import * as logger from "../../utils/logger"; +import { EventEmitter } from "events"; + +export class CDPEventEmitter extends EventEmitter { + on(event: U, listener: (params: Events[U]) => void | Promise): this { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventListenerWithErrorBoundary = (params: Events[U]): void | Promise => { + const logError = (e: unknown): void => { + logger.error(`Catched unhandled error in CDP "${event}" handler: ${(e && (e as Error).stack) || e}`); + }; + + try { + const result = listener(params); + + return result instanceof Promise ? result.catch(logError) : result; + } catch (e) { + logError(e); + } + }; + + return super.on(event, eventListenerWithErrorBoundary); + } +} diff --git a/src/browser/cdp/error.ts b/src/browser/cdp/error.ts new file mode 100644 index 000000000..3d9f15ecd --- /dev/null +++ b/src/browser/cdp/error.ts @@ -0,0 +1,51 @@ +import { CDP_ERROR_CODE } from "./constants"; +import type { CDPRequestId } from "./types"; + +export class CDPError extends Error { + public code?: number; + public requestId?: CDPRequestId; + + constructor({ message, code, requestId }: { message: string; code?: number; requestId?: CDPRequestId }) { + let errorMessage = message; + + if (code) { + errorMessage += `\n\tErrorCode: ${code}`; + } + + if (requestId) { + errorMessage += `\n\tCDP Request ID: ${requestId}`; + } + + super(errorMessage); + + this.name = this.constructor.name; + this.code = code; + this.requestId = requestId; + } + + isNonRetryable(): boolean { + // JSON-RPC Protocol Errors + // CDP State/Execution Errors + // https://www.jsonrpc.org/specification#error_object + return Boolean(this.code && ((this.code >= -32700 && this.code <= -32600) || this.code === -32000)); + } +} + +export class CDPTimeoutError extends CDPError { + constructor({ message, requestId }: { message: string; requestId?: CDPRequestId }) { + super({ message, code: CDP_ERROR_CODE.TIMEOUT, requestId }); + + this.name = this.constructor.name; + } +} + +export class CDPConnectionTerminatedError extends CDPError { + constructor({ + message = "CDP connection was manually closed", + requestId, + }: { message?: string; requestId?: CDPRequestId } = {}) { + super({ message, code: CDP_ERROR_CODE.CONNECTION_TERMINATED, requestId }); + + this.name = this.constructor.name; + } +} diff --git a/src/browser/cdp/index.ts b/src/browser/cdp/index.ts new file mode 100644 index 000000000..3b3941d1b --- /dev/null +++ b/src/browser/cdp/index.ts @@ -0,0 +1,45 @@ +import type { Browser } from "../types"; +import { CDPConnection } from "./connection"; +import { CDPTarget } from "./target"; +import { CDPProfiler } from "./profiler"; +import type { CDPEvent } from "./types"; + +export class CDP { + private readonly _connection: CDPConnection; + public readonly target: CDPTarget; + public readonly profiler: CDPProfiler; + + static async create(browser: Browser): Promise { + if (!browser.publicAPI.isChromium) { + return null; + } + + const connection = await CDPConnection.create(browser).catch(() => null); + + return connection ? new this(connection) : null; + } + + constructor(connection: CDPConnection) { + this._connection = connection; + this.target = new CDPTarget(connection); + this.profiler = new CDPProfiler(connection); + this._connection.onEventMessage = this._onEventMessage.bind(this); + } + + close(): void { + this._connection.close(); + } + + private _onEventMessage(cdpEventMessage: CDPEvent): void { + const [domain, method] = cdpEventMessage.method.split(".", 2); + + switch (domain) { + case "Target": + this.target.emit(method, cdpEventMessage.params); + break; + case "Profiler": + this.profiler.emit(method, cdpEventMessage.params); + break; + } + } +} diff --git a/src/browser/cdp/profiler.ts b/src/browser/cdp/profiler.ts new file mode 100644 index 000000000..1816a41b7 --- /dev/null +++ b/src/browser/cdp/profiler.ts @@ -0,0 +1,90 @@ +import { CDPConnection } from "./connection"; +import { CDPEventEmitter } from "./emitter"; +import type { CDPDebuggerLocation, CDPSessionId, CDPScriptCoverage, CDPProfile } from "./types"; + +interface StartPreciseCoverageRequest { + /** Collect accurate call counts beyond simple 'covered' or 'not covered'. */ + callCount: boolean; + /** Collect block-based coverage. */ + detailed: boolean; + /** Allow the backend to send updates on its own initiative */ + allowTriggeredUpdates: boolean; +} + +interface StartPreciseCoverageResponse { + /** Monotonically increasing time (in seconds) when the coverage update was taken in the backend. */ + timestamp: number; +} + +interface TakePreciseCoverageResponse { + /** Coverage data for the current isolate */ + result: CDPScriptCoverage[]; + /** Monotonically increasing time (in seconds) when the coverage update was taken in the backend. */ + timestamp: number; +} + +export interface ProfilerEvents { + consoleProfileFinished: { + id: string; + /** Location of console.profileEnd(). */ + location: CDPDebuggerLocation; + profile: CDPProfile; + /** Profile title passed as an argument to console.profile(). */ + title?: string; + }; + consoleProfileStarted: { + id: string; + /** Location of console.profile(). */ + location: CDPDebuggerLocation; + /** Profile title passed as an argument to console.profile(). */ + title?: string; + }; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/ */ +export class CDPProfiler extends CDPEventEmitter { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { + super(); + + this._connection = connection; + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/#method-disable + */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("Profiler.disable", { sessionId }); + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/#method-enable + */ + async enable(sessionId: CDPSessionId): Promise { + return this._connection.request("Profiler.enable", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/#method-startPreciseCoverage */ + async startPreciseCoverage( + sessionId: CDPSessionId, + params: StartPreciseCoverageRequest, + ): Promise { + return this._connection.request("Profiler.startPreciseCoverage", { + sessionId, + params, + }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/#method-stopPreciseCoverage */ + async stopPreciseCoverage(sessionId: CDPSessionId): Promise { + return this._connection.request("Profiler.stopPreciseCoverage", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/#method-takePreciseCoverage */ + async takePreciseCoverage(sessionId: CDPSessionId): Promise { + return this._connection.request("Profiler.takePreciseCoverage", { sessionId }); + } +} diff --git a/src/browser/cdp/target.ts b/src/browser/cdp/target.ts new file mode 100644 index 000000000..0201fb20e --- /dev/null +++ b/src/browser/cdp/target.ts @@ -0,0 +1,122 @@ +import { CDPEventEmitter } from "./emitter"; +import { CDPConnection } from "./connection"; +import type { CDPBrowserContextId, CDPSessionId, CDPTargetId, CDPTargetInfo } from "./types"; + +interface GetBrowserContextsResponse { + browserContextIds: CDPBrowserContextId[]; +} + +interface CreateBrowserContextResponse { + browserContextId: CDPBrowserContextId; +} + +interface CreateTargetResponse { + targetId: CDPTargetId; +} + +interface CreateTargetResponse { + targetId: CDPTargetId; +} + +interface AttachToTargetResponse { + sessionId: CDPSessionId; +} + +interface GetTargetsResponse { + targetInfos: CDPTargetInfo[]; +} + +interface SetAutoAttachRequest { + autoAttach: boolean; + waitForDebuggerOnStart: boolean; + flatten?: boolean; +} + +export interface TargetEvents { + receivedMessageFromTarget: { + sessionId: CDPSessionId; + message: string; + }; + targetCrashed: { + targetId: CDPTargetId; + /** Termination status type. */ + status: string; + /** Termination error code. */ + errorCode: number; + }; + targetCreated: { + targetInfo: CDPTargetInfo; + }; + targetDestroyed: { + targetId: CDPTargetId; + }; + targetInfoChanged: { + targetInfo: CDPTargetInfo; + }; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/ */ +export class CDPTarget extends CDPEventEmitter { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { + super(); + + this._connection = connection; + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-getBrowserContexts */ + async getBrowserContexts(): Promise { + return this._connection.request("Target.getBrowserContexts"); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-createBrowserContext */ + async createBrowserContext(): Promise { + return this._connection.request("Target.createBrowserContext"); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-disposeBrowserContext */ + async disposeBrowserContext(browserContextId: CDPBrowserContextId): Promise { + return this._connection.request("Target.disposeBrowserContext", { params: { browserContextId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-createTarget */ + async createTarget({ + url = "about:blank", + browserContextId, + }: { + url?: string; + browserContextId?: CDPBrowserContextId; + }): Promise { + const params = { url, browserContextId }; + + return this._connection.request("Target.createTarget", { params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-closeTarget */ + async closeTarget(targetId: CDPTargetId): Promise { + return this._connection.request("Target.closeTarget", { params: { targetId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-activateTarget */ + async activateTarget(targetId: CDPTargetId): Promise { + return this._connection.request("Target.activateTarget", { params: { targetId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-attachToTarget */ + async attachToTarget(targetId: CDPTargetId): Promise { + const params = { targetId, flatten: true }; + + return this._connection.request("Target.attachToTarget", { params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-getTargets */ + async getTargets(): Promise { + return this._connection.request("Target.getTargets"); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-setAutoAttach */ + async setAutoAttach(sessionId: CDPSessionId, params: SetAutoAttachRequest): Promise { + return this._connection.request("Target.setAutoAttach", { sessionId, params }); + } +} diff --git a/src/browser/cdp/types.ts b/src/browser/cdp/types.ts new file mode 100644 index 000000000..77922ccb2 --- /dev/null +++ b/src/browser/cdp/types.ts @@ -0,0 +1,151 @@ +export type Domain = string; +export type MethodName = string; +export type SessionId = string; +export type Suffix = string; +export type CDPRequestId = number; +export type CDPTargetId = string; +export type CDPSessionId = string; +export type CDPBrowserContextId = string; +export type RuntimeScriptId = string; + +// https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc;l=131-144?q=f:devtools%20-f:out%20%22::kTypeTab%5B%5D%22&ss=chromium +type TargetType = + | "tab" + | "page" + | "iframe" + | "worker" + | "shared_worker" + | "service_worker" + | "worklet" + | "shared_storage_worklet" + | "browser" + | "webview" + | "other" + | "auction_worklet" + | "assistive_technology"; + +export interface CDPRequest> { + id: CDPRequestId; + sessionId?: CDPSessionId; + method: `${Domain}.${MethodName}`; + params?: T; +} + +export interface CDPErrorResponse { + id: CDPRequestId; + error: { + code: number; + message: string; + }; +} + +export interface CDPSuccessResponse> { + id: CDPRequestId; + result: T; +} + +export interface CDPEvent> { + method: `${Domain}.${MethodName}`; + params: T; +} + +export type CDPResponse> = CDPErrorResponse | CDPSuccessResponse; + +export type CDPMessage = CDPResponse | CDPEvent; + +export interface CDPTargetInfo { + targetId: CDPTargetId; + type: TargetType; + title: string; + url: string; + /** Whether the target has an attached client. */ + attached: boolean; + /** Opener target Id */ + openerId?: CDPTargetId; +} + +interface CoverageRange { + /** JavaScript script source offset for the range start. */ + startOffset: number; + /** JavaScript script source offset for the range end. */ + endOffset: number; + /** Collected execution count of the source range. */ + count: number; +} + +interface FunctionCoverage { + /** JavaScript function name. */ + functionName: string; + /** Source ranges inside the function with coverage data. */ + ranges: CoverageRange[]; + /** Whether coverage data for this function has block granularity. */ + isBlockCoverage: boolean; +} + +export interface CDPScriptCoverage { + /** JavaScript script id. */ + scriptId: RuntimeScriptId; + /** JavaScript script name or url. */ + url: string; + /** Functions contained in the script that has coverage data. */ + functions: FunctionCoverage[]; +} + +export interface CDPDebuggerLocation { + /** Script identifier as reported in the Debugger.scriptParsed. */ + scriptId: RuntimeScriptId; + /** Line number in the script (0-based). */ + lineNumber: number; + /** Column number in the script (0-based). */ + columnNumber?: number; +} + +interface RuntimeCallFrame { + /** JavaScript function name. */ + functionName: string; + /** JavaScript script id. */ + scriptId: RuntimeScriptId; + /** JavaScript script name or url. */ + url: string; + /** JavaScript script line number (0-based). */ + lineNumber: number; + /** JavaScript script column number (0-based). */ + columnNumber: number; +} + +interface PositionTickInfo { + /** Source line number (1-based). */ + line: number; + /** Number of samples attributed to the source line. */ + ticks: number; +} + +interface ProfileNode { + /** Unique id of the node. */ + id: number; + /** Function location. */ + callFrame: RuntimeCallFrame; + /** Number of samples where this node was on top of the call stack. */ + hitCount?: number; + /** Child node ids. */ + children?: number[]; + /** The reason of being not optimized. The function may be deoptimized or marked as don't optimize. */ + deoptReason?: string; + /** An array of source position ticks. */ + positionTicks?: PositionTickInfo[]; +} + +export interface CDPProfile { + /** The list of profile nodes. First item is the root node. */ + nodes: ProfileNode[]; + /** Profiling start timestamp in microseconds. */ + startTime: number; + /** Profiling end timestamp in microseconds. */ + endTime: number; + /** Ids of samples top nodes. */ + samples?: number[]; + /** Time intervals between adjacent samples in microseconds. + * The first delta is relative to the profile startTime. + */ + timeDeltas: number[]; +} diff --git a/src/browser/cdp/utils.ts b/src/browser/cdp/utils.ts new file mode 100644 index 000000000..e11142f79 --- /dev/null +++ b/src/browser/cdp/utils.ts @@ -0,0 +1,29 @@ +export const exponentiallyWait = ({ + baseDelay = 500, + attempt = 0, + factor = 2, + jitter = 100, +}: { baseDelay?: number; attempt?: number; factor?: number; jitter?: number } = {}): Promise => { + const delay = Math.round(baseDelay * factor ** attempt + Math.random() * jitter); + + return new Promise(resolve => setTimeout(resolve, delay).unref()); +}; + +export const extractRequestIdFromBrokenResponse = (message: string): number | null => { + const idStartMarker = '{"id":'; + + if (!message.startsWith(idStartMarker)) { + return null; + } + + const idEndPosition = message.indexOf(","); + + if (idEndPosition === -1) { + return null; + } + + const idPart = message.slice(idStartMarker.length, idEndPosition); + const requestId = Number(idPart); + + return isNaN(requestId) ? null : requestId; +}; diff --git a/src/browser/cdp/ws-endpoint.ts b/src/browser/cdp/ws-endpoint.ts new file mode 100644 index 000000000..0de91131a --- /dev/null +++ b/src/browser/cdp/ws-endpoint.ts @@ -0,0 +1,52 @@ +import { debugCdp } from "./debug"; +import type { Browser } from "../types"; + +const endpointsCache = new WeakMap(); + +export const getWsEndpoint = async (browser: Browser): Promise => { + const cachedEndpoint = endpointsCache.get(browser); + + if (cachedEndpoint) { + return cachedEndpoint; + } + + const session = browser.publicAPI; + const caps = session.capabilities; + const chromeOptions = caps["goog:chromeOptions"]; + + // Priority 1: "browserWSEndpoint" + if ("browserWSEndpoint" in browser.config && browser.config.browserWSEndpoint && session.sessionId) { + const urljoin = await import("url-join").then(mod => mod.default); + return urljoin(browser.config.browserWSEndpoint as string, session.sessionId); + } + + // Priority 2: "se:cdp" capability + if (caps["se:cdp"]) { + return caps["se:cdp"]; + } + + // Priority 3: chrome debugger address + if (chromeOptions && chromeOptions.debuggerAddress) { + const versionUrl = `http://${chromeOptions.debuggerAddress}/json/version`; + try { + const cdpResponse = await fetch(versionUrl).then(res => res.json()); + if (cdpResponse && cdpResponse.webSocketDebuggerUrl) { + return cdpResponse.webSocketDebuggerUrl; + } + } catch (err) { + debugCdp(`Couldn't fetch chrome devtools debugger address at "${versionUrl}": ${err}`); + return null; + } + } + + // Priority 4: selenium grid + if (session.sessionId && session.options) { + const hostname = session.options.hostname || "localhost"; + const port = session.options.port || 4444; + const sessionId = session.sessionId; + + return `ws://${hostname}:${port}/session/${sessionId}/se/cdp`; + } + + return null; +}; diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 335241bc4..e3418285e 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -18,6 +18,7 @@ import type { CalibrationResult, Calibrator } from "./calibrator"; import { NEW_ISSUE_LINK } from "../constants/help"; import { runWithoutHistory } from "./history"; import type { SessionOptions } from "./types"; +import { CDP } from "./cdp"; const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"]; @@ -38,6 +39,7 @@ interface ScrollByParams { } const BROWSER_SESSION_HINT = "browser session"; +const CDP_CONNECTION_HINT = "cdp connection"; const CLIENT_BRIDGE_HINT = "client bridge"; function ensure(value: T | undefined | null, hint?: string): asserts value is T { @@ -60,6 +62,7 @@ export class ExistingBrowser extends Browser { protected _meta: Record; protected _calibration?: CalibrationResult; protected _clientBridge?: ClientBridge; + protected _cdp: CDP | null = null; constructor(config: Config, opts: BrowserOpts) { super(config, opts); @@ -72,11 +75,15 @@ export class ExistingBrowser extends Browser { async init({ sessionId, sessionCaps, sessionOpts }: SessionOptions, calibrator: Calibrator): Promise { this._session = await this._attachSession({ sessionId, sessionCaps, sessionOpts }); + const cdpPromise = CDP.create(this).then(cdp => { + this._cdp = cdp; + }); + if (!isRunInNodeJsEnv(this._config)) { this._startCollectingCustomCommands(); } - const isolationPromise = this._performIsolation({ sessionCaps, sessionOpts }); + const isolationPromise = cdpPromise.then(() => this._performIsolation({ sessionCaps, sessionOpts })); this._extendStacktrace(); this._addSteps(); @@ -122,6 +129,7 @@ export class ExistingBrowser extends Browser { } quit(): void { + this._cdp?.close(); this._meta = this._initMeta(); } @@ -324,27 +332,8 @@ export class ExistingBrowser extends Browser { return this._config.baseUrl ? url.resolve(this._config.baseUrl, uri) : uri; } - protected async _performIsolation({ - sessionCaps, - sessionOpts, - }: Pick): Promise { + protected async _performBidiIsolation(sessionOpts: SessionOptions["sessionOpts"]): Promise { ensure(this._session, BROWSER_SESSION_HINT); - if (!this._config.isolation) { - return; - } - - const { - browserName, - browserVersion = "", - version = "", - } = (sessionCaps as SessionOptions["sessionCaps"] & { version?: string }) || {}; - if (!isSupportIsolation(browserName!, browserVersion)) { - logger.warn( - `WARN: test isolation works only with chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} and higher, ` + - `but got ${browserName}@${browserVersion || version}`, - ); - return; - } const puppeteer = await this._session.getPuppeteer(); const browserCtxs = puppeteer.browserContexts(); @@ -373,6 +362,62 @@ export class ExistingBrowser extends Browser { } } + protected async _performCdpIsolation(sessionOpts: SessionOptions["sessionOpts"]): Promise { + ensure(this._session, BROWSER_SESSION_HINT); + ensure(this._cdp, CDP_CONNECTION_HINT); + + const cdpTarget = this._cdp.target; + const [browserContextIds, currentTargets] = await Promise.all([ + cdpTarget.getBrowserContexts().then(res => res.browserContextIds), + cdpTarget.getTargets().then(res => res.targetInfos), + ]); + const browserContextId = await cdpTarget.createBrowserContext().then(res => res.browserContextId); + const incognitoWindowId = await cdpTarget.createTarget({ browserContextId }).then(res => res.targetId); + + if (sessionOpts?.automationProtocol === WEBDRIVER_PROTOCOL) { + const windowIds = await this._session.getWindowHandles(); + + await this._session.switchToWindow(windowIds.find(id => id.includes(incognitoWindowId))!); + } + + await Promise.all([ + cdpTarget.activateTarget(incognitoWindowId), + ...browserContextIds.map(contextId => cdpTarget.disposeBrowserContext(contextId)), + ...currentTargets.map(target => cdpTarget.closeTarget(target.targetId).catch(() => {})), + ]); + } + + protected async _performIsolation({ + sessionCaps, + sessionOpts, + }: Pick): Promise { + ensure(this._session, BROWSER_SESSION_HINT); + if (!this._config.isolation) { + return; + } + + const { + browserName, + browserVersion = "", + version = "", + } = (sessionCaps as SessionOptions["sessionCaps"] & { version?: string }) || {}; + if (!isSupportIsolation(browserName!, browserVersion)) { + logger.warn( + `WARN: test isolation works only with chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} and higher, ` + + `but got ${browserName}@${browserVersion || version}`, + ); + return; + } + + if (this._session.isBidi) { + return this._performBidiIsolation(sessionOpts); + } else if (this._cdp) { + return this._performCdpIsolation(sessionOpts); + } else { + logger.warn("Unable to get CDP endpoint, skip performing isolation"); + } + } + protected async _prepareSession(): Promise { await this._setOrientation(this.config.orientation); await this._setWindowSize(this.config.windowSize); diff --git a/test/src/browser/cdp/connection.ts b/test/src/browser/cdp/connection.ts new file mode 100644 index 000000000..367bae579 --- /dev/null +++ b/test/src/browser/cdp/connection.ts @@ -0,0 +1,475 @@ +import { WebSocket, WebSocketServer } from "ws"; +import sinon, { SinonStub, SinonFakeTimers } from "sinon"; +import proxyquire from "proxyquire"; +import { CDPConnection } from "src/browser/cdp/connection"; +import { CDPError, CDPConnectionTerminatedError } from "src/browser/cdp/error"; +import { CDP_MAX_REQUEST_ID } from "src/browser/cdp/constants"; +import type { CDPEvent, CDPErrorResponse, CDPRequest, CDPResponse } from "src/browser/cdp/types"; +import type { Browser } from "src/browser/types"; + +const STUB_SERVER_PORT = 50123; + +type StubWebSocketServer = WebSocketServer & { + waitForConnection: Promise; + connectionsCounter: number; + requestsCounter: number; + closeConnections: () => void; +}; + +let wsServerConnection: WebSocket | null = null; +let wsServer: StubWebSocketServer | null = null; + +const createWsServer = (): StubWebSocketServer => { + const wss = new WebSocketServer({ port: STUB_SERVER_PORT }) as StubWebSocketServer; + + let resolveHangingPromise: ((ws: WebSocket) => void) | null = null; + let connectionsCounter = 0; + let requestsCounter = 0; + + const hangingPromise = new Promise(resolve => { + resolveHangingPromise = resolve; + }); + + Object.defineProperty(wss, "waitForConnection", { value: hangingPromise }); + Object.defineProperty(wss, "connectionsCounter", { get: () => connectionsCounter }); + Object.defineProperty(wss, "requestsCounter", { get: () => requestsCounter }); + Object.defineProperty(wss, "closeConnections", { value: () => wss.clients.forEach(ws => ws.close()) }); + + wss.on("connection", ws => { + connectionsCounter++; + wsServerConnection = ws; + resolveHangingPromise?.(ws); + + let flakyMethodCounter = 0; + + ws.on("ping", () => ws.pong()); + ws.on("message", data => { + requestsCounter++; + + const { id, method, params, sessionId } = JSON.parse(data.toString("utf8")) as CDPRequest; + + switch (method) { + case "Successful.Method": + ws.send(JSON.stringify({ id, result: { id, params, sessionId } } as CDPResponse)); + break; + + case "Unsuccessful.Method": + ws.send( + JSON.stringify({ + id, + error: { + id, + code: params && "code" in params ? params.code : 0, + message: params && "message" in params ? params.message : "", + }, + } as CDPErrorResponse), + ); + break; + + case "Flaky.Method": { + const respondAfter = params && "respondAfter" in params ? Number(params.respondAfter) : 3; + const message = params && "errorMessage" in params ? params.errorMessage : "error"; + const code = params && "errorCode" in params ? Number(params.errorCode) : -32000; + const dontAnswer = params && "dontAnswer" in params; + + if (flakyMethodCounter++ === respondAfter) { + ws.send(JSON.stringify({ id, result: { id, params, sessionId } } as CDPResponse)); + } else if (!dontAnswer) { + ws.send(JSON.stringify({ id, error: { id, code, message } } as CDPErrorResponse)); + } + + break; + } + } + }); + }); + + return wss; +}; + +const getWsServerConnection = (): WebSocket => { + if (!wsServerConnection) { + throw Error("Connection is not established"); + } + + return wsServerConnection; +}; + +describe('"CDPConnection"', () => { + const sandbox = sinon.createSandbox(); + let clock: SinonFakeTimers; + let getWsEndpointStub: SinonStub; + let exponentiallyWaitStub: SinonStub; + let extractRequestIdFromBrokenResponseStub: SinonStub; + let debugCdpStub: SinonStub; + let CDPConnectionProxied: typeof CDPConnection; + + const mockBrowser = { + publicAPI: { + sessionId: "test-session-id", + }, + } as Browser; + + const mockEndpoint = `ws://localhost:${STUB_SERVER_PORT}`; + + beforeEach(() => { + wsServer = createWsServer(); + clock = sinon.useFakeTimers(); + getWsEndpointStub = sandbox.stub().resolves(mockEndpoint); + exponentiallyWaitStub = sandbox.stub().resolves(); + extractRequestIdFromBrokenResponseStub = sandbox.stub().returns(null); + debugCdpStub = sandbox.stub(); + + CDPConnectionProxied = proxyquire("src/browser/cdp/connection", { + "./ws-endpoint": { getWsEndpoint: getWsEndpointStub }, + "./utils": { + exponentiallyWait: exponentiallyWaitStub, + extractRequestIdFromBrokenResponse: extractRequestIdFromBrokenResponseStub, + }, + "./debug": { debugCdp: debugCdpStub }, + }).CDPConnection; + }); + + afterEach(() => { + clock.restore(); + sandbox.restore(); + wsServer?.closeConnections(); + wsServer?.close(); + }); + + describe("create", () => { + it("should create CDPConnection instance", async () => { + const connection = await CDPConnectionProxied.create(mockBrowser); + + assert.instanceOf(connection, CDPConnectionProxied); + assert.calledOnce(getWsEndpointStub); + assert.calledWith(getWsEndpointStub, mockBrowser); + }); + + it("should throw error if CDP endpoint cannot be determined", async () => { + getWsEndpointStub.resolves(null); + + await assert.isRejected( + CDPConnectionProxied.create(mockBrowser), + CDPError, + "Couldn't determine CDP endpoint for session test-session-id", + ); + }); + }); + + describe("request handling", () => { + let connection: CDPConnection; + + beforeEach(async () => { + connection = await CDPConnectionProxied.create(mockBrowser); + }); + + afterEach(() => { + connection.close(); + }); + + it("should send request and receive successful response", async () => { + const response = await connection.request("Successful.Method", { params: { test: "value" } }); + + assert.deepEqual(response, { id: 1, params: { test: "value" } }); + }); + + it("should handle CDP error response", async () => { + const requestPromise = connection.request("Unsuccessful.Method", { + params: { message: "serverErrorMessage" }, + }); + + await assert.isRejected(requestPromise, CDPError, "serverErrorMessage"); + }); + + it("should generate unique request IDs", async () => { + const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); + const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + // Verify both requests completed successfully + assert.deepEqual(result1, { id: 1, params: { test: "1" } }); + assert.deepEqual(result2, { id: 2, params: { test: "2" } }); + }); + + it("should wrap request ID at maximum value", async () => { + // Set the connection's request ID to near maximum + Object.defineProperty(connection, "requestId", { value: CDP_MAX_REQUEST_ID - 1 }); + + const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); + const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + // Verify both requests completed successfully + assert.deepEqual(result1, { id: CDP_MAX_REQUEST_ID, params: { test: "1" } }); + assert.deepEqual(result2, { id: 1, params: { test: "2" } }); + }); + + it("should handle request with sessionId", async () => { + const result = await connection.request("Successful.Method", { + sessionId: "custom-session-id", + params: { test: "value" }, + }); + + // Verify the request completed successfully and sessionId was passed + assert.deepEqual(result, { + id: 1, + params: { test: "value" }, + sessionId: "custom-session-id", + }); + }); + }); + + // Error codes outside the range -32700 to -32600 and not -32000 are retryable + describe("request retries", () => { + let connection: CDPConnection; + + beforeEach(async () => { + connection = await CDPConnectionProxied.create(mockBrowser); + }); + + afterEach(async () => { + connection.close(); + }); + + it("should retry requests on retryable errors", async () => { + const response = await connection.request("Flaky.Method", { + params: { respondAfter: 3, errorCode: -1000 }, + }); + + // Verify that exponentiallyWait was called for retries + assert.callCount(exponentiallyWaitStub, 3); + assert.equal(wsServer?.requestsCounter, 4); + assert.deepEqual(response, { id: 4, params: { respondAfter: 3, errorCode: -1000 } }); + }); + + it("should stop retrying after maximum attempts", async () => { + // All attempts will fail with retryable error + const requestPromise = connection.request("Unsuccessful.Method", { + params: { message: "Persistent error", code: -30000 }, + }); + + await assert.isRejected(requestPromise, CDPError, "Persistent error"); + assert.equal(wsServer?.requestsCounter, 4); + }); + + it("should not retry non-retryable errors", async () => { + const requestPromise = connection.request("Unsuccessful.Method", { + params: { message: "Persistent error", code: -32000 }, + }); + + await assert.isRejected(requestPromise, CDPError, "Persistent error"); + assert.equal(wsServer?.requestsCounter, 1); + }); + }); + + describe("connection establishment and retries", () => { + let connection: CDPConnection; + + beforeEach(async () => { + connection = await CDPConnectionProxied.create(mockBrowser); + }); + + afterEach(() => { + connection.close(); + }); + + it("should establish connection successfully", async () => { + // Make a request to verify connection is established + const result = await connection.request("Successful.Method", { params: { test: "value" } }); + + assert.deepEqual(result, { id: 1, params: { test: "value" } }); + assert.calledWith(getWsEndpointStub, mockBrowser); + }); + + it("should fail after maximum connection retries", async () => { + // Always return non-existent port + getWsEndpointStub.resolves("ws://localhost:32105"); + + const connection = await CDPConnectionProxied.create(mockBrowser); + const requestPromise = connection.request("Runtime.enable"); + + try { + await assert.isRejected(requestPromise, CDPError, "Couldn't establish CDP connection"); + } finally { + connection.close(); + } + }); + + it("should connect once on multiple requests", async () => { + const [r1, r2, r3] = await Promise.all([ + connection.request("Successful.Method"), + connection.request("Successful.Method"), + connection.request("Successful.Method"), + ]); + + assert.deepEqual(r1, { id: 1 }); + assert.deepEqual(r2, { id: 2 }); + assert.deepEqual(r3, { id: 3 }); + assert.equal(wsServer?.connectionsCounter, 1); + }); + }); + + describe("event handling", () => { + let connection: CDPConnection; + + beforeEach(async () => { + connection = await CDPConnectionProxied.create(mockBrowser); + + await connection.request("Successful.Method"); // Establishes connection + + Object.defineProperty(connection, "requestId", { value: 0 }); + }); + + afterEach(async () => { + connection.close(); + }); + + it("should forward CDP events to event handler", async () => { + // Send CDP event (message without id) + const eventHandler = (connection.onEventMessage = sinon.stub()); + const event: CDPEvent = { + method: "Runtime.consoleAPICalled", + params: { type: "log", args: [{ value: "test" }] }, + }; + + getWsServerConnection().send(JSON.stringify(event)); + + // Await for event to settle + await connection.request("Successful.Method"); + + assert.calledOnceWith(eventHandler, event); + }); + + it("should not forward events if no handler is set", async () => { + // Send CDP event (message without id) + const event: CDPEvent = { + method: "Runtime.consoleAPICalled", + params: { type: "log", args: [{ value: "test" }] }, + }; + + getWsServerConnection().send(JSON.stringify(event)); + + // Await for event to settle + // Should not throw any errors + await connection.request("Successful.Method"); + }); + + it("should handle malformed JSON messages", async () => { + // Send malformed JSON + getWsServerConnection().send("invalid json"); + + // Make a request to ensure connection is still working + const result = await connection.request("Successful.Method", { params: {} }); + + // Should handle malformed JSON gracefully and still process valid requests + assert.deepEqual(result, { id: 1, params: {} }); + }); + + it("should retry + handle malformed response with extractable request ID", async () => { + let brokenRequestIdCounter = 1; + + extractRequestIdFromBrokenResponseStub.callsFake(() => brokenRequestIdCounter++); + + const serverWs = getWsServerConnection(); + + let requestsCount = 0; + // Override server's message handler to send malformed response + serverWs.removeAllListeners("message"); + serverWs.on("message", () => { + requestsCount++; + // Send malformed JSON with extractable request ID + serverWs.send('{"id":1,"invalid"}'); + }); + + const requestPromise = connection.request("Successful.Method"); + await assert.isRejected(requestPromise, CDPError, "Received malformed response: response is invalid JSON"); + assert.equal(requestsCount, 4); + }); + + it("should ignore responses for unknown request IDs", async () => { + await wsServer?.waitForConnection; + const serverWs = getWsServerConnection(); + + // Send response with unknown request ID first + serverWs.send(JSON.stringify({ id: 999, result: {} })); + + // Make a normal request - should work despite the unknown response + const result = await connection.request("Successful.Method", { params: { test: "value" } }); + assert.deepEqual(result, { id: 1, params: { test: "value" } }); + }); + }); + + describe("connection management and reconnection", () => { + let connection: CDPConnection; + + beforeEach(async () => { + connection = await CDPConnectionProxied.create(mockBrowser); + + await connection.request("Successful.Method"); // Establishes connection + + Object.defineProperty(connection, "requestId", { value: 0 }); + }); + + afterEach(async () => { + connection.close(); + }); + + it("should close connection and abort pending requests", async () => { + await wsServer?.waitForConnection; + + const requestPromise = connection.request("Timeout.Method", { params: {} }); + + // Close connection manually before timeout + connection.close(); + + await assert.isRejected(requestPromise, CDPConnectionTerminatedError); + }); + + it("should prevent new requests after close", async () => { + connection.close(); + + const requestPromise = connection.request("Runtime.enable"); + + await assert.isRejected(requestPromise, CDPConnectionTerminatedError); + }); + + it("should reuse existing connection for multiple requests", async () => { + const promise1 = connection.request("Successful.Method"); + const promise2 = connection.request("Successful.Method"); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + assert.deepEqual(result1, { id: 1 }); + assert.deepEqual(result2, { id: 2 }); + }); + + it("should handle connection drop and reconnect", async () => { + const result1 = await connection.request("Successful.Method"); + assert.deepEqual(result1, { id: 1 }); + + // Simulate connection drop + getWsServerConnection().close(); + + // Make second request (should trigger reconnection) + const result2 = await connection.request("Successful.Method"); + assert.deepEqual(result2, { id: 3 }); + }); + + it("should handle connection termination and reconnect", async () => { + const result1 = await connection.request("Successful.Method"); + assert.deepEqual(result1, { id: 1 }); + + // Simulate connection error + getWsServerConnection().terminate(); + + // Make request (should trigger reconnection) + // Request is retried because of termination + const result2 = await connection.request("Successful.Method"); + assert.deepEqual(result2, { id: 3 }); + assert.calledOnce(exponentiallyWaitStub); + }); + }); +}); diff --git a/test/src/browser/existing-browser.js b/test/src/browser/existing-browser.js index 7c4362cdd..dadbe474a 100644 --- a/test/src/browser/existing-browser.js +++ b/test/src/browser/existing-browser.js @@ -15,21 +15,14 @@ const { BROWSER_TEST_RUN_ENV, } = require("src/constants/config"); const { MIN_CHROME_VERSION_SUPPORT_ISOLATION, X_REQUEST_ID_DELIMITER } = require("src/constants/browser"); -const { - mkExistingBrowser_, - mkSessionStub_, - mkCDPStub_, - mkCDPBrowserCtx_, - mkCDPPage_, - mkCDPTarget_, -} = require("./utils"); +const { mkExistingBrowser_, mkSessionStub_ } = require("./utils"); const proxyquire = require("proxyquire"); describe("ExistingBrowser", () => { const sandbox = sinon.createSandbox(); let session; let ExistingBrowser; - let webdriverioAttachStub, clientBridgeBuildStub, loggerWarnStub, initCommandHistoryStub, runGroupStub; + let webdriverioAttachStub, clientBridgeBuildStub, loggerWarnStub, initCommandHistoryStub, runGroupStub, CDPStub; const mkBrowser_ = (configOpts, opts) => { return mkExistingBrowser_(configOpts, opts, ExistingBrowser); @@ -60,6 +53,20 @@ describe("ExistingBrowser", () => { initCommandHistoryStub = sandbox.stub(); runGroupStub = sandbox.stub(); + CDPStub = { + target: { + getBrowserContexts: sandbox.stub().resolves({ browserContextIds: [] }), + createBrowserContext: sandbox.stub().resolves({ browserContextId: "some-browser-context" }), + disposeBrowserContext: sandbox.stub().resolves(), + createTarget: sandbox.stub().resolves({ targetId: "some-target-id" }), + closeTarget: sandbox.stub().resolves(), + activateTarget: sandbox.stub().resolves(), + attachToTarget: sandbox.stub().resolves("some-target-id"), + getTargets: sandbox.stub().resolves({ targetInfos: [] }), + }, + close: sandbox.stub(), + }; + ExistingBrowser = proxyquire("src/browser/existing-browser", { "@testplane/webdriverio": { attach: webdriverioAttachStub, @@ -79,6 +86,11 @@ describe("ExistingBrowser", () => { "./history": { runGroup: runGroupStub.callsFake(history.runGroup), }, + "./cdp": { + CDP: { + create: sandbox.stub().resolves(CDPStub), + }, + }, }).ExistingBrowser; }); @@ -514,23 +526,6 @@ describe("ExistingBrowser", () => { }); describe("perform isolation", () => { - let cdp, incognitoBrowserCtx, incognitoPage, incognitoTarget; - - beforeEach(() => { - incognitoTarget = mkCDPTarget_(); - incognitoPage = mkCDPPage_(); - incognitoPage.target.returns(incognitoTarget); - - incognitoBrowserCtx = mkCDPBrowserCtx_(); - incognitoBrowserCtx.newPage.resolves(incognitoPage); - incognitoBrowserCtx.isIncognito.returns(true); - - cdp = mkCDPStub_(); - cdp.createIncognitoBrowserContext.resolves(incognitoBrowserCtx); - - session.getPuppeteer.resolves(cdp); - }); - describe("should do nothing if", () => { it("'isolation' option is not specified", async () => { await initBrowser_(mkBrowser_({ isolation: false })); @@ -558,14 +553,14 @@ describe("ExistingBrowser", () => { describe("should warn that isolation doesn't work in", () => { it("chrome browser (w3c)", async () => { - const sessionCaps = { browserName: "chrome", browserVersion: "90.0" }; + const sessionCaps = { browserName: "chrome", browserVersion: "60.0" }; await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); assert.calledOnceWith( loggerWarnStub, `WARN: test isolation works only with chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} and higher, ` + - "but got chrome@90.0", + "but got chrome@60.0", ); }); @@ -582,12 +577,12 @@ describe("ExistingBrowser", () => { }); }); - it("should create incognito browser context", async () => { + it("should create browser context", async () => { const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); - assert.calledOnceWithExactly(cdp.createIncognitoBrowserContext); + assert.calledOnceWithExactly(CDPStub.target.createBrowserContext); }); it("should get current browser contexts before create incognito", async () => { @@ -595,15 +590,16 @@ describe("ExistingBrowser", () => { await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); - assert.callOrder(cdp.browserContexts, cdp.createIncognitoBrowserContext); + assert.callOrder(CDPStub.target.getBrowserContexts, CDPStub.target.createBrowserContext); }); - it("should create new page inside incognito browser context", async () => { + it("should create new page inside new browser context", async () => { + CDPStub.target.createBrowserContext.resolves({ browserContextId: "new-browser-context" }); const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); - assert.calledOnceWithExactly(incognitoBrowserCtx.newPage); + assert.calledOnceWithExactly(CDPStub.target.createTarget, { browserContextId: "new-browser-context" }); }); it("should work with chrome-headless-shell", async () => { @@ -611,12 +607,16 @@ describe("ExistingBrowser", () => { await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); - assert.callOrder(cdp.browserContexts, cdp.createIncognitoBrowserContext, incognitoBrowserCtx.newPage); + assert.callOrder( + CDPStub.target.getBrowserContexts, + CDPStub.target.createBrowserContext, + CDPStub.target.createTarget, + ); }); describe(`in "${WEBDRIVER_PROTOCOL}" protocol`, () => { it("should switch to incognito window", async () => { - incognitoTarget._targetId = "456"; + CDPStub.target.createTarget.resolves({ targetId: "456" }); session.getWindowHandles.resolves(["window_123", "window_456", "window_789"]); const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; @@ -625,7 +625,7 @@ describe("ExistingBrowser", () => { await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps, sessionOpts }); assert.calledOnceWith(session.switchToWindow, "window_456"); - assert.callOrder(incognitoBrowserCtx.newPage, session.getWindowHandles); + assert.callOrder(CDPStub.target.createTarget, session.getWindowHandles); }); }); @@ -636,56 +636,40 @@ describe("ExistingBrowser", () => { await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps, sessionOpts }); - assert.notCalled(session.getWindowHandles); assert.notCalled(session.switchToWindow); }); }); it("should close pages in default browser context", async () => { - const defaultBrowserCtx = mkCDPBrowserCtx_(); - const page1 = mkCDPPage_(); - const page2 = mkCDPPage_(); - defaultBrowserCtx.pages.resolves([page1, page2]); - - cdp.browserContexts.returns([defaultBrowserCtx, incognitoBrowserCtx]); + CDPStub.target.getBrowserContexts.resolves({ browserContextIds: ["other-browser-id"] }); const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); - assert.calledOnceWithExactly(page1.close); - assert.calledOnceWithExactly(page2.close); - assert.notCalled(incognitoPage.close); + assert.calledOnceWithExactly(CDPStub.target.disposeBrowserContext, "other-browser-id"); }); it("should close incognito browser context", async () => { - const defaultBrowserCtx = mkCDPBrowserCtx_(); - cdp.browserContexts.returns([defaultBrowserCtx, incognitoBrowserCtx]); + CDPStub.target.getBrowserContexts.resolves({ browserContextIds: ["other-browser-id"] }); const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); - assert.calledOnceWithExactly(incognitoBrowserCtx.close); - assert.notCalled(defaultBrowserCtx.close); + assert.calledOnceWith(CDPStub.target.disposeBrowserContext, "other-browser-id"); }); it("should not close pages in BiDi protocol", async () => { webdriverioAttachStub.resolves({ ...mkSessionStub_(), isBidi: true }); - const defaultBrowserCtx = mkCDPBrowserCtx_(); - const page1 = mkCDPPage_(); - const page2 = mkCDPPage_(); - defaultBrowserCtx.pages.resolves([page1, page2]); - cdp.browserContexts.returns([defaultBrowserCtx, incognitoBrowserCtx]); + CDPStub.target.getBrowserContexts.resolves({ browserContextIds: ["other-browser-context"] }); const sessionCaps = { browserName: "chrome", browserVersion: "100.0", webSocketUrl: true }; await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); - assert.notCalled(page1.close); - assert.notCalled(page2.close); - assert.notCalled(incognitoPage.close); + assert.notCalled(CDPStub.target.disposeBrowserContext); }); }); @@ -1164,5 +1148,13 @@ describe("ExistingBrowser", () => { assert.equal(browser.meta.pid, pid); }); + + it("should close opened CDP connection", async () => { + const browser = await initBrowser_(); + + browser.quit(); + + assert.calledOnceWith(CDPStub.close); + }); }); }); diff --git a/test/src/config/browser-options.js b/test/src/config/browser-options.js index e6c875c4e..2d256b542 100644 --- a/test/src/config/browser-options.js +++ b/test/src/config/browser-options.js @@ -1342,7 +1342,7 @@ describe("config browser-options", () => { b1: mkBrowser_({ desiredCapabilities: { browserName: "chrome", - browserVersion: "90.0", + browserVersion: "60.0", }, }), }, diff --git a/test/src/runner/test-runner/insistant-test-runner.js b/test/src/runner/test-runner/insistant-test-runner.js index cad9a4199..d7b58a4fe 100644 --- a/test/src/runner/test-runner/insistant-test-runner.js +++ b/test/src/runner/test-runner/insistant-test-runner.js @@ -138,7 +138,7 @@ describe("runner/test-runner/insistant-test-runner", () => { }); }; - it("should not retry successfull test", async () => { + it("should not retry successful test", async () => { onFirstTestRun_(innerRunner => innerRunner.emit(Events.TEST_PASS)); await run_({ runner: mkRunnerWithRetries_() }); diff --git a/tsconfig.common.json b/tsconfig.common.json index a65fda3ce..0a0717589 100644 --- a/tsconfig.common.json +++ b/tsconfig.common.json @@ -1,7 +1,7 @@ { "include": ["src"], "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "es2021"], + "lib": ["DOM", "DOM.Iterable", "es2022"], "allowJs": true, "declaration": true, "declarationMap": false,