Skip to content

Commit 7782bfd

Browse files
feat: add bidi client
1 parent 34f67b4 commit 7782bfd

61 files changed

Lines changed: 4272 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.eslintrc.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ module.exports = {
2020
"@typescript-eslint/no-var-requires": "off",
2121
},
2222
},
23+
{
24+
// Generated 1:1 WebDriver BiDi protocol typings contain forward and recursive
25+
// type references, which TypeScript hoists safely.
26+
files: ["src/browser/bidi/types/**/*.ts"],
27+
rules: {
28+
"no-use-before-define": "off",
29+
},
30+
},
2331
{
2432
files: ["test/**"],
2533
rules: {

src/browser/bidi/connection.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { RawData } from "ws";
2+
import { inspect } from "node:util";
3+
import { debugBidi } from "./debug";
4+
import {
5+
BIDIConnectionBreakError,
6+
BIDIConnectionEstablishmentError,
7+
BIDIConnectionTerminatedError,
8+
BIDIConnectionTimeoutError,
9+
BIDIRequestError,
10+
BIDIRequestTimeoutError,
11+
} from "./error";
12+
import {
13+
BIDI_CONNECTION_RETRIES,
14+
BIDI_CONNECTION_RETRY_BASE_DELAY,
15+
BIDI_CONNECTION_TIMEOUT,
16+
BIDI_REQUEST_RETRIES,
17+
BIDI_REQUEST_RETRY_BASE_DELAY,
18+
} from "./constants";
19+
import type { BiDiMessage, BiDiEvent, BiDiCommand } from "./types";
20+
import type { Browser } from "../types";
21+
import { WsConnection } from "../../ws-connection";
22+
import { WsError } from "../../ws-connection/error";
23+
import { exponentiallyWait } from "../../ws-connection/utils";
24+
25+
type OnEventMessageFn = (bidiEventMessage: BiDiEvent) => unknown;
26+
27+
interface BiDiConnectionOptions {
28+
sessionId: string;
29+
headers?: Record<string, string>;
30+
requestTimeout: number;
31+
}
32+
33+
export class BIDIConnection {
34+
public onEventMessage: OnEventMessageFn | null = null;
35+
private readonly _sessionId: string;
36+
37+
private readonly _wsConnection: WsConnection<Record<string, unknown>, string>;
38+
39+
private constructor(bidiWsEndpoint: string, { headers, sessionId, requestTimeout }: BiDiConnectionOptions) {
40+
this._sessionId = sessionId;
41+
42+
this._wsConnection = new WsConnection<Record<string, unknown>, string>(bidiWsEndpoint, {
43+
headers,
44+
debugFn: debugBidi,
45+
retries: {
46+
count: BIDI_CONNECTION_RETRIES,
47+
baseDelay: BIDI_CONNECTION_RETRY_BASE_DELAY,
48+
},
49+
timeouts: {
50+
request: requestTimeout,
51+
createSession: BIDI_CONNECTION_TIMEOUT,
52+
},
53+
errors: {
54+
ConnectionEstablishment: BIDIConnectionEstablishmentError,
55+
ConnectionBreak: BIDIConnectionBreakError,
56+
ConnectionTerminated: BIDIConnectionTerminatedError,
57+
ConnectionTimeout: BIDIConnectionTimeoutError,
58+
RequestTimeout: BIDIRequestError,
59+
},
60+
onMessage: this._onMessage.bind(this),
61+
});
62+
}
63+
64+
/** @description Creates BIDIConnection without establishing it */
65+
static create(browser: Browser): BIDIConnection | null {
66+
const sessionId = browser.publicAPI.sessionId;
67+
68+
const bidiWsEndpoint = browser.publicAPI.capabilities.webSocketUrl as unknown as string;
69+
const headers = browser.publicAPI.options?.headers ?? {};
70+
71+
if (!bidiWsEndpoint) {
72+
return null;
73+
}
74+
75+
return new this(bidiWsEndpoint, { headers, sessionId, requestTimeout: browser.config.httpTimeout });
76+
}
77+
78+
close(): void {
79+
this._wsConnection.close();
80+
}
81+
82+
private _onMessage(data: RawData): void {
83+
const message = data.toString("utf8");
84+
85+
try {
86+
const jsonParsedMessage: BiDiMessage = JSON.parse(message);
87+
88+
if (debugBidi.enabled) {
89+
debugBidi(
90+
`< ${inspect(
91+
{ sessionId: this._sessionId, ...jsonParsedMessage },
92+
{
93+
depth: 3,
94+
maxStringLength: 150,
95+
breakLength: Infinity,
96+
compact: true,
97+
},
98+
)}`,
99+
);
100+
}
101+
102+
if (!("id" in jsonParsedMessage)) {
103+
if (this.onEventMessage) {
104+
this.onEventMessage(jsonParsedMessage);
105+
}
106+
107+
return;
108+
}
109+
110+
if (jsonParsedMessage.type === "error" && jsonParsedMessage.id === null) {
111+
debugBidi(
112+
`\u2718 Received error message without ID: ${jsonParsedMessage.message}\n${jsonParsedMessage.stacktrace}`,
113+
);
114+
return;
115+
}
116+
117+
if (jsonParsedMessage.type === "success") {
118+
this._wsConnection.provideResponseFor(jsonParsedMessage.id, jsonParsedMessage.result);
119+
} else if (jsonParsedMessage.type === "error") {
120+
this._wsConnection.provideResponseFor(
121+
jsonParsedMessage.id as number,
122+
new BIDIRequestError({
123+
message: jsonParsedMessage.message,
124+
code: jsonParsedMessage.error,
125+
requestId: jsonParsedMessage.id as number,
126+
}),
127+
);
128+
}
129+
} catch (err) {
130+
if (debugBidi.enabled) {
131+
const data = [
132+
`\u2718 Couldn't process BIDI message`,
133+
`\tSession ID: ${this._sessionId}`,
134+
`\tError: ${inspect(
135+
{ sessionId: this._sessionId, ...(err || {}) },
136+
{
137+
depth: 3,
138+
maxStringLength: 150,
139+
breakLength: Infinity,
140+
compact: true,
141+
},
142+
)}`,
143+
`\tMessage: "${message}"`,
144+
];
145+
debugBidi(data.join("\n"));
146+
}
147+
}
148+
}
149+
150+
/** @description Performs high-level CDP request with retries and timeouts */
151+
async request<T = void>(method: BiDiCommand["method"], params: BiDiCommand["params"] = {}): Promise<T> {
152+
let result!: T | WsError;
153+
154+
for (let retriesLeft = BIDI_REQUEST_RETRIES; retriesLeft >= 0; retriesLeft--) {
155+
const id = this._wsConnection.getRequestId();
156+
const requestMessage = JSON.stringify({ id, method, params });
157+
158+
if (debugBidi.enabled) {
159+
debugBidi(
160+
`> ${inspect(
161+
{
162+
sessionId: this._sessionId,
163+
id,
164+
method,
165+
params,
166+
},
167+
{
168+
depth: 3,
169+
maxStringLength: 150,
170+
breakLength: Infinity,
171+
compact: true,
172+
},
173+
)}`,
174+
);
175+
}
176+
177+
result = (await this._wsConnection.makeRequest(id, requestMessage)) as T | WsError;
178+
179+
if (!(result instanceof WsError) || !result.isRetryable() || retriesLeft <= 0) {
180+
break;
181+
}
182+
183+
if (debugBidi.enabled) {
184+
debugBidi(
185+
`⟳ ${inspect({
186+
sessionId: this._sessionId,
187+
id,
188+
method,
189+
params,
190+
errorMessage: result.message,
191+
retriesLeft: retriesLeft,
192+
})}`,
193+
);
194+
}
195+
196+
// Intentionally avoiding wait after timeout
197+
if (!(result instanceof BIDIRequestTimeoutError)) {
198+
await exponentiallyWait({
199+
baseDelay: BIDI_REQUEST_RETRY_BASE_DELAY,
200+
attempt: BIDI_REQUEST_RETRIES - retriesLeft,
201+
});
202+
}
203+
}
204+
205+
if (result instanceof WsError) {
206+
throw result;
207+
}
208+
209+
return result;
210+
}
211+
}

src/browser/bidi/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const BIDI_CONNECTION_TIMEOUT = 15000; // 15 sec
2+
export const BIDI_CONNECTION_RETRIES = 3;
3+
export const BIDI_CONNECTION_RETRY_BASE_DELAY = 500;
4+
export const BIDI_REQUEST_RETRIES = 3;
5+
export const BIDI_REQUEST_RETRY_BASE_DELAY = 500;

src/browser/bidi/debug.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import debugModule from "debug";
2+
3+
export const debugBidi = debugModule("testplane:bidi");

src/browser/bidi/emitter.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as logger from "../../utils/logger";
2+
import { EventEmitter } from "events";
3+
4+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5+
type AnyFunc = (...args: any) => unknown;
6+
7+
export class BIDIEmitter<Events extends { [key in keyof Events]: unknown }> extends EventEmitter {
8+
private _callbackMap: Map<AnyFunc, AnyFunc> = new Map();
9+
10+
on<U extends string & keyof Events>(event: U, listener: (params: Events[U]) => void | Promise<void>): this {
11+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12+
const eventListenerWithErrorBoundary = (params: Events[U]): void | Promise<void> => {
13+
const logError = (e: unknown): void => {
14+
logger.error(`Catched unhandled error in BiDi "${event}" handler: ${(e && (e as Error).stack) || e}`);
15+
};
16+
17+
try {
18+
const result = listener(params);
19+
20+
return result instanceof Promise ? result.catch(logError) : result;
21+
} catch (e) {
22+
logError(e);
23+
}
24+
};
25+
26+
this._callbackMap.set(listener, eventListenerWithErrorBoundary);
27+
28+
return super.on(event, eventListenerWithErrorBoundary);
29+
}
30+
31+
off<U extends string & keyof Events>(event: U, listener: (params: Events[U]) => void | Promise<void>): this {
32+
const eventListenerWithErrorBoundary = this._callbackMap.get(listener);
33+
34+
if (eventListenerWithErrorBoundary) {
35+
this._callbackMap.delete(listener);
36+
super.off(event, eventListenerWithErrorBoundary);
37+
}
38+
39+
return this;
40+
}
41+
}

src/browser/bidi/error.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
WsError,
3+
WsConnectionEstablishmentError,
4+
WsConnectionTerminatedError,
5+
WsConnectionBreakError,
6+
WsConnectionTimeoutError,
7+
WsRequestTimeoutError,
8+
} from "../../ws-connection/error";
9+
10+
export class BIDIError extends WsError {
11+
isRetryable(): boolean {
12+
return false;
13+
}
14+
}
15+
16+
export class BIDIConnectionEstablishmentError extends WsConnectionEstablishmentError {}
17+
export class BIDIConnectionBreakError extends WsConnectionBreakError {}
18+
export class BIDIConnectionTerminatedError extends WsConnectionTerminatedError {}
19+
export class BIDIConnectionTimeoutError extends WsConnectionTimeoutError {}
20+
export class BIDIRequestTimeoutError extends WsRequestTimeoutError {}
21+
22+
export class BIDIRequestError extends WsError {
23+
public stacktrace?: string;
24+
25+
constructor({
26+
message,
27+
code,
28+
requestId,
29+
stacktrace,
30+
}: {
31+
message: string;
32+
code?: number | string;
33+
requestId?: number;
34+
stacktrace?: string;
35+
}) {
36+
super({ message, requestId, code });
37+
38+
this.stacktrace = stacktrace;
39+
40+
if (stacktrace) {
41+
this.message += `\n\tStacktrace:\n${stacktrace}`;
42+
}
43+
}
44+
45+
isRetryable(): boolean {
46+
return (
47+
this.code === "unknown error" ||
48+
this.code === "unable to capture screen" ||
49+
this.code === "unable to close browser"
50+
);
51+
}
52+
}

0 commit comments

Comments
 (0)