Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ module.exports = {
"@typescript-eslint/no-var-requires": "off",
},
},
{
// Generated 1:1 WebDriver BiDi protocol typings contain forward and recursive
// type references, which TypeScript hoists safely.
files: ["src/browser/bidi/types/**/*.ts"],
rules: {
"no-use-before-define": "off",
},
},
{
files: ["test/**"],
rules: {
Expand Down
211 changes: 211 additions & 0 deletions src/browser/bidi/connection.ts

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First file with some code (which is 80% similar to CDP one)

Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import type { RawData } from "ws";
import { inspect } from "node:util";
import { debugBidi } from "./debug";
import {
BIDIConnectionBreakError,
BIDIConnectionEstablishmentError,
BIDIConnectionTerminatedError,
BIDIConnectionTimeoutError,
BIDIRequestError,
BIDIRequestTimeoutError,
} from "./error";
import {
BIDI_CONNECTION_RETRIES,
BIDI_CONNECTION_RETRY_BASE_DELAY,
BIDI_CONNECTION_TIMEOUT,
BIDI_REQUEST_RETRIES,
BIDI_REQUEST_RETRY_BASE_DELAY,
} from "./constants";
import type { BiDiMessage, BiDiEvent, BiDiCommand } from "./types";
import type { Browser } from "../types";
import { WsConnection } from "../../ws-connection";
import { WsError } from "../../ws-connection/error";
import { exponentiallyWait } from "../../ws-connection/utils";

type OnEventMessageFn = (bidiEventMessage: BiDiEvent) => unknown;

interface BiDiConnectionOptions {
sessionId: string;
headers?: Record<string, string>;
requestTimeout: number;
}

export class BIDIConnection {
public onEventMessage: OnEventMessageFn | null = null;
private readonly _sessionId: string;

private readonly _wsConnection: WsConnection<Record<string, unknown>, string>;

private constructor(bidiWsEndpoint: string, { headers, sessionId, requestTimeout }: BiDiConnectionOptions) {
this._sessionId = sessionId;

this._wsConnection = new WsConnection<Record<string, unknown>, string>(bidiWsEndpoint, {
headers,
debugFn: debugBidi,
retries: {
count: BIDI_CONNECTION_RETRIES,
baseDelay: BIDI_CONNECTION_RETRY_BASE_DELAY,
},
timeouts: {
request: requestTimeout,
createSession: BIDI_CONNECTION_TIMEOUT,
},
errors: {
ConnectionEstablishment: BIDIConnectionEstablishmentError,
ConnectionBreak: BIDIConnectionBreakError,
ConnectionTerminated: BIDIConnectionTerminatedError,
ConnectionTimeout: BIDIConnectionTimeoutError,
RequestTimeout: BIDIRequestError,
},
onMessage: this._onMessage.bind(this),
});
}

/** @description Creates BIDIConnection without establishing it */
static create(browser: Browser): BIDIConnection | null {
const sessionId = browser.publicAPI.sessionId;

const bidiWsEndpoint = browser.publicAPI.capabilities.webSocketUrl as unknown as string;
const headers = browser.publicAPI.options?.headers ?? {};

if (!bidiWsEndpoint) {
return null;
}

return new this(bidiWsEndpoint, { headers, sessionId, requestTimeout: browser.config.httpTimeout });
}

close(): void {
this._wsConnection.close();
}

private _onMessage(data: RawData): void {
const message = data.toString("utf8");

try {
const jsonParsedMessage: BiDiMessage = JSON.parse(message);

if (debugBidi.enabled) {
debugBidi(
`< ${inspect(
{ sessionId: this._sessionId, ...jsonParsedMessage },
{
depth: 3,
maxStringLength: 150,
breakLength: Infinity,
compact: true,
},
)}`,
);
}

if (!("id" in jsonParsedMessage)) {
if (this.onEventMessage) {
this.onEventMessage(jsonParsedMessage);
}

return;
}

if (jsonParsedMessage.type === "error" && jsonParsedMessage.id === null) {
debugBidi(
`\u2718 Received error message without ID: ${jsonParsedMessage.message}\n${jsonParsedMessage.stacktrace}`,
);
return;
}

if (jsonParsedMessage.type === "success") {
this._wsConnection.provideResponseFor(jsonParsedMessage.id, jsonParsedMessage.result);
} else if (jsonParsedMessage.type === "error") {
this._wsConnection.provideResponseFor(
jsonParsedMessage.id as number,
new BIDIRequestError({
message: jsonParsedMessage.message,
code: jsonParsedMessage.error,
requestId: jsonParsedMessage.id as number,
}),
);
}
} catch (err) {
if (debugBidi.enabled) {
const data = [
`\u2718 Couldn't process BIDI message`,
`\tSession ID: ${this._sessionId}`,
`\tError: ${inspect(
{ sessionId: this._sessionId, ...(err || {}) },
{
depth: 3,
maxStringLength: 150,
breakLength: Infinity,
compact: true,
},
)}`,
`\tMessage: "${message}"`,
];
debugBidi(data.join("\n"));
}
}
}

/** @description Performs high-level CDP request with retries and timeouts */
async request<T = void>(method: BiDiCommand["method"], params: BiDiCommand["params"] = {}): Promise<T> {
let result!: T | WsError;

for (let retriesLeft = BIDI_REQUEST_RETRIES; retriesLeft >= 0; retriesLeft--) {
const id = this._wsConnection.getRequestId();
const requestMessage = JSON.stringify({ id, method, params });

if (debugBidi.enabled) {
debugBidi(
`> ${inspect(
{
sessionId: this._sessionId,
id,
method,
params,
},
{
depth: 3,
maxStringLength: 150,
breakLength: Infinity,
compact: true,
},
)}`,
);
}

result = (await this._wsConnection.makeRequest(id, requestMessage)) as T | WsError;

if (!(result instanceof WsError) || !result.isRetryable() || retriesLeft <= 0) {
break;
}

if (debugBidi.enabled) {
debugBidi(
`⟳ ${inspect({
sessionId: this._sessionId,
id,
method,
params,
errorMessage: result.message,
retriesLeft: retriesLeft,
})}`,
);
}

// Intentionally avoiding wait after timeout
if (!(result instanceof BIDIRequestTimeoutError)) {
await exponentiallyWait({
baseDelay: BIDI_REQUEST_RETRY_BASE_DELAY,
attempt: BIDI_REQUEST_RETRIES - retriesLeft,
});
}
}

if (result instanceof WsError) {
throw result;
}

return result;
}
}
5 changes: 5 additions & 0 deletions src/browser/bidi/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const BIDI_CONNECTION_TIMEOUT = 15000; // 15 sec
export const BIDI_CONNECTION_RETRIES = 3;
export const BIDI_CONNECTION_RETRY_BASE_DELAY = 500;
export const BIDI_REQUEST_RETRIES = 3;
export const BIDI_REQUEST_RETRY_BASE_DELAY = 500;
3 changes: 3 additions & 0 deletions src/browser/bidi/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import debugModule from "debug";

export const debugBidi = debugModule("testplane:bidi");
41 changes: 41 additions & 0 deletions src/browser/bidi/emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as logger from "../../utils/logger";
import { EventEmitter } from "events";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFunc = (...args: any) => unknown;

export class BIDIEmitter<Events extends { [key in keyof Events]: unknown }> extends EventEmitter {
private _callbackMap: Map<AnyFunc, AnyFunc> = new Map();

on<U extends string & keyof Events>(event: U, listener: (params: Events[U]) => void | Promise<void>): this {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventListenerWithErrorBoundary = (params: Events[U]): void | Promise<void> => {
const logError = (e: unknown): void => {
logger.error(`Catched unhandled error in BiDi "${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);
}
};

this._callbackMap.set(listener, eventListenerWithErrorBoundary);

return super.on(event, eventListenerWithErrorBoundary);
}

off<U extends string & keyof Events>(event: U, listener: (params: Events[U]) => void | Promise<void>): this {
const eventListenerWithErrorBoundary = this._callbackMap.get(listener);

if (eventListenerWithErrorBoundary) {
this._callbackMap.delete(listener);
super.off(event, eventListenerWithErrorBoundary);
}

return this;
}
}
52 changes: 52 additions & 0 deletions src/browser/bidi/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
WsError,
WsConnectionEstablishmentError,
WsConnectionTerminatedError,
WsConnectionBreakError,
WsConnectionTimeoutError,
WsRequestTimeoutError,
} from "../../ws-connection/error";

export class BIDIError extends WsError {
isRetryable(): boolean {
return false;
}
}

export class BIDIConnectionEstablishmentError extends WsConnectionEstablishmentError {}
export class BIDIConnectionBreakError extends WsConnectionBreakError {}
export class BIDIConnectionTerminatedError extends WsConnectionTerminatedError {}
export class BIDIConnectionTimeoutError extends WsConnectionTimeoutError {}
export class BIDIRequestTimeoutError extends WsRequestTimeoutError {}

export class BIDIRequestError extends WsError {
public stacktrace?: string;

constructor({
message,
code,
requestId,
stacktrace,
}: {
message: string;
code?: number | string;
requestId?: number;
stacktrace?: string;
}) {
super({ message, requestId, code });

this.stacktrace = stacktrace;

if (stacktrace) {
this.message += `\n\tStacktrace:\n${stacktrace}`;
}
}

isRetryable(): boolean {
return (
this.code === "unknown error" ||
this.code === "unable to capture screen" ||
this.code === "unable to close browser"
);
}
}
Loading
Loading