import logger from '@wdio/logger' import type { ClientOptions, RawData, WebSocket } from 'ws' import { environment } from '../environment.js' import type * as remote from './remoteTypes.js' import type * as local from './localTypes.js' import type { CommandData } from './remoteTypes.js' import type { CommandResponse, ErrorResponse } from './localTypes.js' import type { Client } from '../types.js' import { isBase64Safe } from './utils.js' const SCRIPT_PREFIX = '/* __wdio script__ */' const SCRIPT_SUFFIX = '/* __wdio script end__ */' const log = logger('webdriver') const RESPONSE_TIMEOUT = 1000 * 60 export class BidiCore { #id = 0 #ws: WebSocket | undefined #waitForConnected: Promise #resolveWaitForConnected: (value: boolean) => void #webSocketUrl: string #clientOptions: ClientOptions | undefined #pendingCommands: Map void> = new Map() client: Client | undefined /** * @private */ private _isConnected = false constructor (webSocketUrl: string, opts?: ClientOptions) { this.#webSocketUrl = webSocketUrl this.#clientOptions = opts this.#resolveWaitForConnected = () => {} this.#waitForConnected = new Promise((resolve) => { this.#resolveWaitForConnected = resolve }) } /** * We initiate the Bidi instance before a WebdriverIO instance is created. * In order to emit Bidi events we have to attach the WebdriverIO instance * to the Bidi instance afterwards. */ public attachClient (client: Client) { this.client = client } public async connect () { log.info(`Connecting to webSocketUrl ${this.#webSocketUrl}`) /** * try to connect to different websocket urls depending on the protocol */ this.#ws = await environment.value.createBidiConnection(this.#webSocketUrl, this.#clientOptions) this._isConnected = Boolean(this.#ws) this.#resolveWaitForConnected(this._isConnected) if (this.#ws) { this.#ws.on('message', this.#handleResponse.bind(this)) } return this._isConnected } public close () { if (!this._isConnected) { return } log.info(`Close Bidi connection to ${this.#webSocketUrl}`) this._isConnected = false if (this.#ws) { this.#ws.off('message', this.#handleResponse.bind(this)) this.#ws.close() this.#ws.terminate() this.#ws = undefined } } public reconnect (webSocketUrl: string, opts?: ClientOptions) { log.info(`Reconnect to new Bidi session at ${webSocketUrl}`) this.close() this.#webSocketUrl = webSocketUrl this.#clientOptions = opts return this.connect() } /** * Helper function that allows to wait until Bidi connection establishes * @returns a promise that resolves once the connection to WebDriver Bidi protocol was established */ waitForConnected () { return this.#waitForConnected } get socket () { return this.#ws } get isConnected () { return this._isConnected } /** * for testing purposes only * @internal */ get __handleResponse () { return this.#handleResponse.bind(this) } #handleResponse (data: RawData) { try { const payload = JSON.parse(data.toString()) as CommandResponse if (!payload.id) { return } /** * If the result is a base64 encoded string, we want to log a simplified version * of the result instead of the raw base64 encoded string */ let resultLog = data.toString() if (typeof payload.result === 'object' && payload.result && 'data' in payload.result && typeof payload.result.data === 'string' && isBase64Safe(payload.result.data)) { resultLog = JSON.stringify({ ...payload.result, data: `Base64 string [${payload.result.data.length} chars]` }) } log.info('BIDI RESULT', resultLog) this.client?.emit('bidiResult', payload) const resolve = this.#pendingCommands.get(payload.id) if (!resolve) { log.error(`Couldn't resolve command with id ${payload.id}`) return } this.#pendingCommands.delete(payload.id) resolve(payload) } catch (err) { const error = err instanceof Error ? err : new Error(`Failed parse message: ${String(err)}`) log.error(`Failed parse message: ${error.message}`) } } public async send (params: Omit): Promise { const id = this.sendAsync(params) const failError = new Error(`WebDriver Bidi command "${params.method}" failed`) const payload = await new Promise((resolve, reject) => { const t = setTimeout(() => { reject(new Error(`Command ${params.method} with id ${id} (with the following parameter: ${JSON.stringify(params.params)}) timed out`)) this.#pendingCommands.delete(id) }, RESPONSE_TIMEOUT) this.#pendingCommands.set(id, (payload) => { clearTimeout(t) resolve(payload) }) }) if (payload.type === 'error' || 'error' in payload) { const error = payload as local.ErrorResponse failError.message += ` with error: ${payload.error} - ${error.message}` if (error.stacktrace && typeof error.stacktrace === 'string') { const driverStack = error.stacktrace .split('\n') .filter(Boolean) .map((line: string) => ` at ${line}`) .join('\n') failError.stack += `\n\nDriver Stack:\n${driverStack}` } throw failError } return payload } public sendAsync (params: Omit) { if (!this.#ws || !this._isConnected) { throw new Error('No connection to WebDriver Bidi was established') } log.info('BIDI COMMAND', ...parseBidiCommand(params)) const id = ++this.#id this.client?.emit('bidiCommand', params) this.#ws.send(JSON.stringify({ id, ...params })) return id } } export function parseBidiCommand (params: Omit) { const commandName = params.method if (commandName === 'script.addPreloadScript') { const param = params.params as remote.ScriptAddPreloadScriptParameters const logString = `{ functionDeclaration: , contexts: ${JSON.stringify(param.contexts)} }` return [commandName, logString] } else if (commandName === 'script.callFunction') { const param = params.params as remote.ScriptCallFunctionParameters const fn = param.functionDeclaration let fnName = '' /** * extract function name from script when it's a function call from the 'webdriverio' package */ if (fn.includes(SCRIPT_PREFIX)) { const internalFn = fn.slice( fn.indexOf(SCRIPT_PREFIX) + SCRIPT_PREFIX.length, fn.indexOf(SCRIPT_SUFFIX) ) const functionPrefix = 'function ' /** * we can only extract function name if it's a named function */ if (internalFn.startsWith(functionPrefix)) { fnName = internalFn.slice( internalFn.indexOf(functionPrefix) + functionPrefix.length, internalFn.indexOf('(') ) } } const logString = JSON.stringify({ ...param, functionDeclaration: `` }) return [commandName, logString] } return [commandName, JSON.stringify(params.params)] }