From 2f44d519fb5580e37c1392a3e45599617eac0b64 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Fri, 27 Mar 2026 19:27:11 +0000 Subject: [PATCH] feat: enable BiDi support for connect-existing mode GeckodriverHttpDriver now requests webSocketUrl: true in the session capabilities and exposes a getBidi() method that opens a WebSocket to Firefox's Remote Agent. This allows ConsoleEvents and NetworkEvents (which depend on BiDi subscriptions) to work when using --connect-existing, without going through selenium-webdriver's createSession() which hangs during BiDi auto-upgrade. The BiDi WebSocket is opened lazily on the first getBidi() call and closed on quit/kill. The subscribe() helper sends session.subscribe commands and waits for the matching response. The socket property gives ConsoleEvents and NetworkEvents direct access to the event stream, matching the interface they already use on the Selenium WebDriver path. No behavior change for launch mode (which continues to use selenium-webdriver with enableBidi). No behavior change for connect-existing when Firefox was not started with --remote-debugging-port (getBidi throws a clear error). --- src/firefox/core.ts | 88 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/src/firefox/core.ts b/src/firefox/core.ts index 0c68d86..27a936a 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -5,6 +5,7 @@ import { Builder, Browser } from 'selenium-webdriver'; import firefox from 'selenium-webdriver/firefox.js'; import { spawn, type ChildProcess } from 'node:child_process'; +import WebSocket from 'ws'; import type { FirefoxLaunchOptions } from './types.js'; import { log, logDebug } from '../utils/logger.js'; @@ -107,18 +108,22 @@ class GeckodriverElement implements IElement { * * This exists because selenium-webdriver's Driver.createSession() tries to * auto-upgrade to BiDi WebSocket, which hangs when connecting to an existing - * Firefox instance. By talking directly to geckodriver's HTTP API we avoid - * the BiDi issue entirely. + * Firefox instance. By talking directly to geckodriver's HTTP API we can + * create the session without hanging, then manually open the BiDi WebSocket + * using the webSocketUrl returned in the session capabilities. */ class GeckodriverHttpDriver implements IDriver { private baseUrl: string; private sessionId: string; private gdProcess: ChildProcess; + private webSocketUrl: string | null; + private bidiConnection: { subscribe: (event: string, contexts?: string[]) => Promise; socket: WebSocket } | null = null; - constructor(baseUrl: string, sessionId: string, gdProcess: ChildProcess) { + constructor(baseUrl: string, sessionId: string, gdProcess: ChildProcess, webSocketUrl: string | null) { this.baseUrl = baseUrl; this.sessionId = sessionId; this.gdProcess = gdProcess; + this.webSocketUrl = webSocketUrl; } static async connect(marionettePort: number): Promise { @@ -191,11 +196,11 @@ class GeckodriverHttpDriver implements IDriver { const baseUrl = `http://127.0.0.1:${port}`; - // Create a WebDriver session + // Create a WebDriver session with BiDi opt-in const resp = await fetch(`${baseUrl}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ capabilities: { alwaysMatch: {} } }), + body: JSON.stringify({ capabilities: { alwaysMatch: { webSocketUrl: true } } }), }); const json = (await resp.json()) as { value: { sessionId: string; capabilities: Record }; @@ -204,7 +209,14 @@ class GeckodriverHttpDriver implements IDriver { throw new Error(`Failed to create session: ${JSON.stringify(json)}`); } - return new GeckodriverHttpDriver(baseUrl, json.value.sessionId, gd); + const wsUrl = json.value.capabilities.webSocketUrl as string | undefined; + if (wsUrl) { + logDebug(`BiDi WebSocket URL: ${wsUrl}`); + } else { + logDebug('BiDi WebSocket URL not available (Firefox may not support it or Remote Agent is not running)'); + } + + return new GeckodriverHttpDriver(baseUrl, json.value.sessionId, gd, wsUrl ?? null); } private async cmd(method: string, path: string, body?: unknown): Promise { @@ -406,7 +418,67 @@ class GeckodriverHttpDriver implements IDriver { return builder; } + /** + * Return a BiDi handle compatible with the interface used by ConsoleEvents + * and NetworkEvents. Opens a WebSocket to Firefox's Remote Agent on first + * call, using the webSocketUrl returned in the session capabilities. + */ + async getBidi(): Promise<{ subscribe: (event: string, contexts?: string[]) => Promise; socket: WebSocket }> { + if (this.bidiConnection) return this.bidiConnection; + if (!this.webSocketUrl) { + throw new Error( + 'BiDi is not available: no webSocketUrl in session capabilities. ' + + 'Ensure Firefox was started with --remote-debugging-port.' + ); + } + + const ws = new WebSocket(this.webSocketUrl); + await new Promise((resolve, reject) => { + ws.on('open', resolve); + ws.on('error', reject); + }); + logDebug('BiDi WebSocket connected'); + + let cmdId = 0; + const subscribe = async (event: string, contexts?: string[]): Promise => { + const msg: Record = { + id: ++cmdId, + method: 'session.subscribe', + params: { events: [event] }, + }; + if (contexts) msg.params = { events: [event], contexts }; + ws.send(JSON.stringify(msg)); + // Wait for the response matching our command id + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`BiDi subscribe timeout for ${event}`)), 5000); + const onMsg = (data: WebSocket.Data) => { + try { + const payload = JSON.parse(data.toString()); + if (payload.id === cmdId) { + clearTimeout(timeout); + ws.off('message', onMsg); + if (payload.error) { + reject(new Error(`BiDi subscribe error: ${payload.error}`)); + } else { + resolve(); + } + } + } catch { /* ignore parse errors from event messages */ } + }; + ws.on('message', onMsg); + }); + logDebug(`BiDi subscribed to ${event}`); + }; + + this.bidiConnection = { subscribe, socket: ws }; + return this.bidiConnection; + } + async quit(): Promise { + if (this.bidiConnection) { + this.bidiConnection.socket.close(); + this.bidiConnection = null; + } try { await this.cmd('DELETE', ''); } catch { @@ -417,6 +489,10 @@ class GeckodriverHttpDriver implements IDriver { /** Kill the geckodriver process without closing Firefox */ kill(): void { + if (this.bidiConnection) { + this.bidiConnection.socket.close(); + this.bidiConnection = null; + } this.gdProcess.kill(); } }