diff --git a/src/cli.ts b/src/cli.ts index ab973a6..db0b4df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -110,13 +110,6 @@ export const cliOptions = { description: 'Marionette port to connect to when using --connect-existing (default: 2828)', default: Number(process.env.MARIONETTE_PORT ?? '2828'), }, - marionetteHost: { - type: 'string', - description: - 'Marionette host to connect to when using --connect-existing (default: 127.0.0.1).' + - ' Also used as the BiDi WebSocket connect address when different from 127.0.0.1.', - default: process.env.MARIONETTE_HOST ?? '127.0.0.1', - }, env: { type: 'array', description: diff --git a/src/firefox/core.ts b/src/firefox/core.ts index 452de7b..6673f96 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -2,613 +2,71 @@ * Core WebDriver + BiDi connection management */ -import { Builder, Browser } from 'selenium-webdriver'; +import { Builder, Browser, Capabilities, WebDriver } from 'selenium-webdriver'; import firefox from 'selenium-webdriver/firefox.js'; -import { spawn, type ChildProcess } from 'node:child_process'; import { mkdirSync, openSync, closeSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import WebSocket from 'ws'; import type { FirefoxLaunchOptions } from './types.js'; import { log, logDebug } from '../utils/logger.js'; // --------------------------------------------------------------------------- -// Shared driver interface — the minimal surface used by all consumers -// (DomInteractions, PageManagement, SnapshotManager, UidResolver). -// Both selenium WebDriver and GeckodriverHttpDriver satisfy this contract. -// --------------------------------------------------------------------------- - -export interface IElement { - click(): Promise; - clear(): Promise; - sendKeys(...args: Array): Promise; - isDisplayed(): Promise; - takeScreenshot(): Promise; -} - -export interface IBiDiSocket { - readyState: number; - on(event: string, listener: (data: unknown) => void): void; - off(event: string, listener: (data: unknown) => void): void; - send(data: string): void; -} - -export interface IBiDi { - socket: IBiDiSocket; - subscribe?: (event: string, contexts?: string[]) => Promise; -} - -/* eslint-disable @typescript-eslint/no-explicit-any */ -export interface IDriver { - getTitle(): Promise; - getCurrentUrl(): Promise; - getWindowHandle(): Promise; - getAllWindowHandles(): Promise; - get(url: string): Promise; - getPageSource(): Promise; - executeScript(script: string | ((...a: any[]) => any), ...args: unknown[]): Promise; - executeAsyncScript(script: string | ((...a: any[]) => any), ...args: unknown[]): Promise; - takeScreenshot(): Promise; - close(): Promise; - findElement(locator: any): Promise; - switchTo(): { - window(handle: string): Promise; - newWindow(type: string): Promise<{ handle: string }>; - alert(): Promise<{ - accept(): Promise; - dismiss(): Promise; - getText(): Promise; - sendKeys(text: string): Promise; - }>; - }; - navigate(): { - back(): Promise; - forward(): Promise; - refresh(): Promise; - }; - manage(): { - window(): { - setRect(rect: { width: number; height: number }): Promise; - }; - }; - actions(opts?: { async?: boolean }): { - move(opts: { x?: number; y?: number; origin?: unknown }): any; - click(): any; - doubleClick(el?: unknown): any; - perform(): Promise; - clear(): Promise; - }; - getBidi(): Promise; -} -/* eslint-enable @typescript-eslint/no-explicit-any */ - -// W3C WebDriver element identifier — the spec-defined key used to represent -// element references in the JSON wire protocol. -// See https://www.w3.org/TR/webdriver2/#elements -const W3C_ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf'; - -// --------------------------------------------------------------------------- -// GeckodriverElement — wraps a raw WebDriver element reference for HTTP API -// --------------------------------------------------------------------------- - -class GeckodriverElement implements IElement { - constructor( - private cmd: (method: string, path: string, body?: unknown) => Promise, - private elementId: string - ) {} - - async click(): Promise { - await this.cmd('POST', `/element/${this.elementId}/click`, {}); - } - - async clear(): Promise { - await this.cmd('POST', `/element/${this.elementId}/clear`, {}); - } - - async sendKeys(...args: Array): Promise { - const text = args.join(''); - await this.cmd('POST', `/element/${this.elementId}/value`, { text }); - } - - async isDisplayed(): Promise { - return (await this.cmd('GET', `/element/${this.elementId}/displayed`)) as boolean; - } - - async takeScreenshot(): Promise { - return (await this.cmd('GET', `/element/${this.elementId}/screenshot`)) as string; - } - - toJSON(): Record { - return { [W3C_ELEMENT_KEY]: this.elementId }; - } -} - -// --------------------------------------------------------------------------- -// GeckodriverHttpDriver +// Geckodriver binary finder — used only for --connect-existing mode // --------------------------------------------------------------------------- /** - * Thin wrapper around geckodriver HTTP API that implements the subset of - * WebDriver interface used by firefox-devtools-mcp. - * - * 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. + * Finds the geckodriver binary path via selenium-manager. + * Uses --driver (not --browser) to avoid downloading Firefox, which is + * already running in connect-existing mode. */ -class GeckodriverHttpDriver implements IDriver { - private baseUrl: string; - private sessionId: string; - private gdProcess: ChildProcess; - private webSocketUrl: string | null; - private bidiConnection: IBiDi | null = null; - - 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, - marionetteHost = '127.0.0.1' - ): Promise { - // Find geckodriver binary via selenium-manager - const path = await import('node:path'); - const { execFileSync } = await import('node:child_process'); - - let geckodriverPath: string; - try { - // selenium-manager ships with selenium-webdriver and resolves/downloads geckodriver. - // Use --driver instead of --browser to skip downloading Firefox, which is - // already running externally in connect-existing mode. - const { createRequire } = await import('node:module'); - const require = createRequire(import.meta.url); - const swPkg = require.resolve('selenium-webdriver/package.json'); - const swDir = path.dirname(swPkg); - const platform = - process.platform === 'win32' - ? 'windows' - : process.platform === 'darwin' - ? 'macos' - : 'linux'; - const ext = process.platform === 'win32' ? '.exe' : ''; - const smBin = path.join(swDir, 'bin', platform, `selenium-manager${ext}`); - const result = JSON.parse( - execFileSync(smBin, ['--driver', 'geckodriver', '--output', 'json'], { encoding: 'utf-8' }) - ); - geckodriverPath = result.result.driver_path; - } catch { - // Fallback: walk the selenium cache directory to find any geckodriver binary - const os = await import('node:os'); - const fs = await import('node:fs'); - const cacheBase = path.join(os.homedir(), '.cache/selenium/geckodriver'); - geckodriverPath = findGeckodriverInCache(fs, path, cacheBase); - if (!geckodriverPath) { - throw new Error('Cannot find geckodriver binary. Ensure selenium-webdriver is installed.'); - } - } - logDebug(`Using geckodriver: ${geckodriverPath}`); - - // Use --port=0 to let the OS assign a free port atomically (geckodriver ≥0.34.0) - const gd = spawn( - geckodriverPath, - [ - '--connect-existing', - '--marionette-host', - marionetteHost, - '--marionette-port', - String(marionettePort), - '--port', - '0', - ], - { stdio: ['ignore', 'pipe', 'pipe'] } - ); - - // Wait for geckodriver to start listening and extract the assigned port - const port = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Geckodriver startup timeout')), 10000); - const onData = (data: Buffer) => { - const msg = data.toString(); - logDebug(`[geckodriver] ${msg.trim()}`); - const match = msg.match(/Listening on\s+\S+:(\d+)/); - if (match?.[1]) { - clearTimeout(timeout); - resolve(parseInt(match[1], 10)); - } - }; - // Listen on both stdout and stderr — geckodriver's output stream varies by version/platform - gd.stdout?.on('data', onData); - gd.stderr?.on('data', onData); - gd.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - gd.on('exit', (code) => { - clearTimeout(timeout); - reject(new Error(`Geckodriver exited with code ${code}`)); - }); - }); - - const baseUrl = `http://127.0.0.1:${port}`; +async function findGeckodriver(): Promise { + const path = await import('node:path'); + const { execFileSync } = await import('node:child_process'); - // 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: { webSocketUrl: true } } }), - }); - const json = (await resp.json()) as { - value: { sessionId: string; capabilities: Record }; - }; - if (!json.value?.sessionId) { - throw new Error(`Failed to create session: ${JSON.stringify(json)}`); - } - - let wsUrl = json.value.capabilities.webSocketUrl as string | undefined; - logDebug( - `Session capabilities webSocketUrl: ${wsUrl ?? 'not present'}, marionetteHost: ${marionetteHost}` + try { + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const swPkg = require.resolve('selenium-webdriver/package.json'); + const swDir = path.dirname(swPkg); + const platform = + process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux'; + const ext = process.platform === 'win32' ? '.exe' : ''; + const smBin = path.join(swDir, 'bin', platform, `selenium-manager${ext}`); + const result = JSON.parse( + execFileSync(smBin, ['--driver', 'geckodriver', '--output', 'json'], { encoding: 'utf-8' }) ); - if (wsUrl && marionetteHost !== '127.0.0.1') { - // Rewrite the URL to connect through the remote host / tunnel. - const parsed = new URL(wsUrl); - parsed.hostname = marionetteHost; - wsUrl = parsed.toString(); - } - 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 { - const url = `${this.baseUrl}/session/${this.sessionId}${path}`; - const opts: RequestInit = { - method, - headers: { 'Content-Type': 'application/json' }, - }; - if (body !== undefined) { - opts.body = JSON.stringify(body); - } - const resp = await fetch(url, opts); - const json = (await resp.json()) as { value: unknown }; - if (json.value && typeof json.value === 'object' && 'error' in json.value) { - const err = json.value as Record; - throw new Error(`${err.error}: ${err.message}`); - } - return json.value; - } - - // WebDriver-compatible methods used by the rest of the codebase - async getTitle(): Promise { - return (await this.cmd('GET', '/title')) as string; - } - async getCurrentUrl(): Promise { - return (await this.cmd('GET', '/url')) as string; - } - async getWindowHandle(): Promise { - return (await this.cmd('GET', '/window')) as string; - } - async getAllWindowHandles(): Promise { - return (await this.cmd('GET', '/window/handles')) as string[]; - } - async get(url: string): Promise { - await this.cmd('POST', '/url', { url }); - } - async getPageSource(): Promise { - return (await this.cmd('GET', '/source')) as string; - } - async executeScript(script: string, ...args: unknown[]): Promise { - return (await this.cmd('POST', '/execute/sync', { script, args })) as T; - } - async executeAsyncScript(script: string, ...args: unknown[]): Promise { - return (await this.cmd('POST', '/execute/async', { script, args })) as T; - } - async takeScreenshot(): Promise { - return (await this.cmd('GET', '/screenshot')) as string; - } - async close(): Promise { - await this.cmd('DELETE', '/window'); - } - async getSession(): Promise<{ getId(): string }> { - return { getId: () => this.sessionId }; - } - - // Element finding - async findElement(locator: Record): Promise { - // Accept selenium By objects (which have using/value) and raw {using, value} objects - const loc = locator as { using?: string; value?: string }; - const using = loc.using ?? 'css selector'; - const value = loc.value ?? ''; - const result = (await this.cmd('POST', '/element', { using, value })) as Record; - // WebDriver protocol returns { "element-xxx": "id" } or { ELEMENT: "id" } - const elementId = Object.values(result)[0]!; - return new GeckodriverElement(this.cmd.bind(this), elementId); - } - - async findElements(locator: Record): Promise { - const loc = locator as { using?: string; value?: string }; - const using = loc.using ?? 'css selector'; - const value = loc.value ?? ''; - const results = (await this.cmd('POST', '/elements', { using, value })) as Array< - Record - >; - return results.map((r) => new GeckodriverElement(this.cmd.bind(this), Object.values(r)[0]!)); - } - - // Polling wait — compatible with selenium's Condition objects and plain functions. - // Used by dom.ts helpers for element location and visibility polling. - async wait( - condition: - | { fn: (driver: any) => T | Promise | null } - | ((driver: any) => T | Promise | null), - timeout = 5000 - ): Promise { - const fn = typeof condition === 'function' ? condition : condition.fn; - const deadline = Date.now() + timeout; - let lastError: Error | undefined; - while (Date.now() < deadline) { - try { - const result = await fn(this); - if (result) { - return result; - } - } catch (e) { - lastError = e instanceof Error ? e : new Error(String(e)); - } - await new Promise((r) => setTimeout(r, 100)); - } - throw lastError ?? new Error(`wait() timed out after ${timeout}ms`); - } - - switchTo() { - return { - window: async (handle: string): Promise => { - await this.cmd('POST', '/window', { handle }); - }, - newWindow: async (type: string): Promise<{ handle: string }> => { - return (await this.cmd('POST', '/window/new', { type })) as { handle: string }; - }, - alert: async () => { - return { - accept: async (): Promise => { - await this.cmd('POST', '/alert/accept'); - }, - dismiss: async (): Promise => { - await this.cmd('POST', '/alert/dismiss'); - }, - getText: async (): Promise => { - return (await this.cmd('GET', '/alert/text')) as string; - }, - sendKeys: async (text: string): Promise => { - await this.cmd('POST', '/alert/text', { text }); - }, - }; - }, - }; - } - - navigate() { - return { - back: async (): Promise => { - await this.cmd('POST', '/back'); - }, - forward: async (): Promise => { - await this.cmd('POST', '/forward'); - }, - refresh: async (): Promise => { - await this.cmd('POST', '/refresh'); - }, - }; - } - - manage() { - return { - window: () => { - return { - setRect: async (rect: { width: number; height: number }): Promise => { - await this.cmd('POST', '/window/rect', rect); - }, - }; - }, - }; - } - - actions(_opts?: { async?: boolean }) { - // Accumulate action sequences for the W3C Actions API - const actionSequences: unknown[] = []; - const builder = { - move: (opts: { x?: number; y?: number; origin?: unknown }) => { - actionSequences.push({ - type: 'pointer', - id: 'mouse', - parameters: { pointerType: 'mouse' }, - actions: [{ type: 'pointerMove', ...opts }], - }); - return builder; - }, - click: () => { - const last = actionSequences[actionSequences.length - 1] as - | { actions: unknown[] } - | undefined; - if (last) { - last.actions.push({ type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }); - } - return builder; - }, - doubleClick: (_el?: unknown) => { - const last = actionSequences[actionSequences.length - 1] as - | { actions: unknown[] } - | undefined; - if (last) { - last.actions.push( - { type: 'pointerDown', button: 0 }, - { type: 'pointerUp', button: 0 }, - { type: 'pointerDown', button: 0 }, - { type: 'pointerUp', button: 0 } - ); - } - return builder; - }, - perform: async (): Promise => { - await this.cmd('POST', '/actions', { actions: actionSequences }); - }, - clear: async (): Promise => { - await this.cmd('DELETE', '/actions'); - }, - }; - return builder; - } - - async quit(): Promise { - if (this.bidiConnection) { - (this.bidiConnection.socket as unknown as WebSocket).close(); - this.bidiConnection = null; - } - try { - await this.cmd('DELETE', ''); - } catch { - // ignore - } - this.gdProcess.kill(); - } - - /** Kill the geckodriver process without closing Firefox. - * Deletes the session first so Marionette accepts new connections. */ - async kill(): Promise { - if (this.bidiConnection) { - (this.bidiConnection.socket as unknown as WebSocket).close(); - this.bidiConnection = null; - } + return result.result.driver_path as string; + } catch { + // Fallback: walk the selenium cache directory to find any geckodriver binary + const os = await import('node:os'); + const fs = await import('node:fs'); + const cacheBase = path.join(os.homedir(), '.cache/selenium/geckodriver'); + const ext = process.platform === 'win32' ? '.exe' : ''; + const binaryName = `geckodriver${ext}`; try { - await this.cmd('DELETE', ''); - } catch { - // ignore - } - this.gdProcess.kill(); - } - - /** - * Return a BiDi handle. Opens a WebSocket to Firefox's Remote Agent on - * first call, using the webSocketUrl returned in the session capabilities. - */ - async getBidi(): Promise { - 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', (e: any) => { - const msg = - e?.message || e?.error?.message || e?.error || e?.type || JSON.stringify(e) || String(e); - reject(new Error(`BiDi WS to ${this.webSocketUrl}: ${msg}`)); - }); - }); - - 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)); - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`BiDi subscribe timeout for ${event}`)), - 5000 - ); - const onMsg = (data: WebSocket.Data) => { - try { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - const payload = JSON.parse(String(data)); - if (payload.id === cmdId) { - clearTimeout(timeout); - ws.off('message', onMsg); - if (payload.error) { - reject(new Error(`BiDi subscribe error: ${payload.error}`)); - } else { - resolve(); - } + if (fs.existsSync(cacheBase)) { + for (const platformDir of fs.readdirSync(cacheBase)) { + const platformPath = path.join(cacheBase, platformDir); + if (!fs.statSync(platformPath).isDirectory()) { + continue; + } + for (const versionDir of fs.readdirSync(platformPath).sort().reverse()) { + const candidate = path.join(platformPath, versionDir, binaryName); + if (fs.existsSync(candidate)) { + return candidate; } - } catch { - /* ignore parse errors from event messages */ } - }; - ws.on('message', onMsg); - }); - logDebug(`BiDi subscribed to ${event}`); - }; - - const conn: IBiDi = { subscribe, socket: ws as unknown as IBiDiSocket }; - this.bidiConnection = conn; - return conn; - } -} - -// --------------------------------------------------------------------------- -// Geckodriver cache walker — finds any geckodriver binary cross-platform -// --------------------------------------------------------------------------- - -function findGeckodriverInCache( - fs: typeof import('node:fs'), - path: typeof import('node:path'), - cacheBase: string -): string { - const ext = process.platform === 'win32' ? '.exe' : ''; - const binaryName = `geckodriver${ext}`; - - try { - if (!fs.existsSync(cacheBase)) { - return ''; - } - - // Walk: cacheBase///geckodriver[.exe] - for (const platformDir of fs.readdirSync(cacheBase)) { - const platformPath = path.join(cacheBase, platformDir); - if (!fs.statSync(platformPath).isDirectory()) { - continue; - } - - // Sort version dirs descending so we prefer the newest - const versionDirs = fs.readdirSync(platformPath).sort().reverse(); - for (const versionDir of versionDirs) { - const candidate = path.join(platformPath, versionDir, binaryName); - if (fs.existsSync(candidate)) { - return candidate; } } + } catch { + // ignore permission errors } - } catch { - // Ignore permission errors etc. + throw new Error('Cannot find geckodriver binary. Ensure selenium-webdriver is installed.'); } - return ''; } export class FirefoxCore { - private driver: IDriver | null = null; + private driver: WebDriver | null = null; private currentContextId: string | null = null; private originalEnv: Record = {}; private logFilePath: string | undefined; @@ -627,12 +85,30 @@ export class FirefoxCore { } if (this.options.connectExisting) { - // Connect to existing Firefox via geckodriver HTTP API directly. - // We bypass selenium-webdriver because its BiDi auto-upgrade hangs - // when used with geckodriver's --connect-existing mode. const port = this.options.marionettePort ?? 2828; - const host = this.options.marionetteHost ?? '127.0.0.1'; - this.driver = await GeckodriverHttpDriver.connect(port, host); + + // Find geckodriver binary (--driver avoids downloading Firefox via selenium-manager) + const geckodriverPath = await findGeckodriver(); + logDebug(`Using geckodriver: ${geckodriverPath}`); + + // Build a geckodriver service that connects to the running Firefox. + // ServiceBuilder already knows about --connect-existing and skips --websocket-port. + const serviceBuilder = new firefox.ServiceBuilder(geckodriverPath); + serviceBuilder.addArguments('--connect-existing', `--marionette-port=${port}`); + + // Use minimal capabilities: only request webSocketUrl for BiDi. + // Deliberately avoid firefox.Options() here — its constructor sets + // moz:firefoxOptions.prefs.remote.active-protocols = 1, which geckodriver + // may apply to the running Firefox via Marionette. Changing that preference + // on a live Firefox can disrupt the Remote Agent and leave the Marionette + // session in a locked state that blocks reconnection. + const caps = new Capabilities(); + caps.set('webSocketUrl', true); + + // createSession() returns synchronously; the session is established async under the hood. + // Passing geckodriverPath to ServiceBuilder prevents getBinaryPaths() from running, + // which would otherwise invoke selenium-manager with --browser firefox. + this.driver = firefox.Driver.createSession(caps, serviceBuilder.build()); } else { // Set up output file for capturing Firefox stdout/stderr if (this.options.logFile) { @@ -709,12 +185,11 @@ export class FirefoxCore { log(`📝 Capturing Firefox output to: ${this.logFilePath}`); } - // selenium WebDriver satisfies IDriver structurally at runtime - this.driver = (await new Builder() + this.driver = await new Builder() .forBrowser(Browser.FIREFOX) .setFirefoxOptions(firefoxOptions) .setFirefoxService(serviceBuilder) - .build()) as unknown as IDriver; + .build(); } log( @@ -739,7 +214,7 @@ export class FirefoxCore { /** * Get driver instance (throw if not connected) */ - getDriver(): IDriver { + getDriver(): WebDriver { if (!this.driver) { throw new Error('Driver not connected'); } @@ -768,8 +243,15 @@ export class FirefoxCore { * Reset driver state (used when Firefox is detected as closed) */ reset(): void { - if (this.driver && this.options.connectExisting && 'kill' in this.driver) { - void (this.driver as { kill(): Promise }).kill(); + if (this.driver) { + const d = this.driver as any; + if (d._bidiConnection) { + d._bidiConnection.close(); + d._bidiConnection = undefined; + } + if ('quit' in this.driver) { + void (this.driver as { quit(): Promise }).quit(); + } } this.driver = null; this.currentContextId = null; @@ -842,7 +324,9 @@ export class FirefoxCore { } const bidi = await this.driver.getBidi(); - const ws = bidi.socket; + // bidi.socket is a Node.js `ws` WebSocket (EventEmitter-style), but typed as browser WebSocket + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ws = bidi.socket as any; // Wait for WebSocket to be ready before sending await this.waitForWebSocketOpen(ws); @@ -890,9 +374,19 @@ export class FirefoxCore { */ async close(): Promise { if (this.driver) { - if (this.options.connectExisting && 'kill' in this.driver) { - await (this.driver as { kill(): Promise }).kill(); - } else if ('quit' in this.driver) { + // Selenium's quit() skips closing the BiDi WebSocket when onQuit_ is set + // (it returns early before reaching the _bidiConnection.close() branch). + // We must close it first: geckodriver may not release the Marionette session + // until the BiDi connection is cleanly terminated, which would leave Firefox's + // Marionette locked and prevent reconnection. + const d = this.driver as any; + if (d._bidiConnection) { + d._bidiConnection.close(); + d._bidiConnection = undefined; + } + // In connect-existing mode, geckodriver's DELETE /session releases Marionette + // without terminating Firefox (since geckodriver was started with --connect-existing). + if ('quit' in this.driver) { await (this.driver as { quit(): Promise }).quit(); } this.driver = null; diff --git a/src/firefox/dom.ts b/src/firefox/dom.ts index 1b7fb6c..93f429d 100644 --- a/src/firefox/dom.ts +++ b/src/firefox/dom.ts @@ -2,13 +2,12 @@ * DOM interactions: evaluate, element lookup, input actions */ -import { Key } from 'selenium-webdriver'; -import type { IDriver, IElement } from './core.js'; +import { By, Key, WebDriver, WebElement } from 'selenium-webdriver'; export class DomInteractions { constructor( - private driver: IDriver, - private resolveUid?: (uid: string) => Promise + private driver: WebDriver, + private resolveUid?: (uid: string) => Promise ) {} /** @@ -27,18 +26,18 @@ export class DomInteractions { } // ============================================================================ - // Element polling helpers (work with both WebDriver and GeckodriverHttpDriver) + // Element polling helpers // ============================================================================ /** * Poll for an element matching a CSS selector until found or timeout. */ - private async waitForElement(selector: string, timeout = 5000): Promise { + private async waitForElement(selector: string, timeout = 5000): Promise { const deadline = Date.now() + timeout; let lastError: Error | undefined; while (Date.now() < deadline) { try { - return await this.driver.findElement({ using: 'css selector', value: selector }); + return await this.driver.findElement(By.css(selector)); } catch (e) { lastError = e instanceof Error ? e : new Error(String(e)); } @@ -50,7 +49,7 @@ export class DomInteractions { /** * Wait until an element reports isDisplayed(), ignoring failures. */ - private async waitForVisible(el: IElement, timeout = 5000): Promise { + private async waitForVisible(el: WebElement, timeout = 5000): Promise { const deadline = Date.now() + timeout; while (Date.now() < deadline) { try { diff --git a/src/firefox/index.ts b/src/firefox/index.ts index 7708d13..85808e0 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -3,7 +3,9 @@ */ import type { FirefoxLaunchOptions, ConsoleMessage } from './types.js'; -import { FirefoxCore, type IElement } from './core.js'; +import { WebElement } from 'selenium-webdriver'; +import { FirefoxCore } from './core.js'; +import { logDebug } from '../utils/logger.js'; import { ConsoleEvents, NetworkEvents } from './events/index.js'; import { DomInteractions } from './dom.js'; import { PageManagement } from './pages.js'; @@ -44,10 +46,11 @@ export class FirefoxClient { } }; - // Initialize event modules with lifecycle hooks - // BiDi-dependent modules (console/network events) require a full WebDriver with - // BiDi support. When using --connect-existing, we bypass selenium's BiDi layer - // so these modules are not available. + // Initialize event modules with lifecycle hooks. + // BiDi (console/network events) is available in both launch and connect-existing + // modes, provided Firefox has its Remote Agent running. If webSocketUrl is absent + // from the session capabilities (e.g. Firefox started without --remote-debugging-port), + // the subscribe calls below will fail gracefully and the modules will be disabled. const hasBidi = 'getBidi' in driver && typeof driver.getBidi === 'function'; if (hasBidi) { @@ -74,12 +77,26 @@ export class FirefoxClient { (id: string) => this.core.setCurrentContextId(id) ); - // Subscribe to console and network events for ALL contexts (not just current) + // Subscribe to console and network events for ALL contexts (not just current). + // Failures here are non-fatal: Firefox may not have the Remote Agent / BiDi + // enabled (e.g. launched with --marionette only, no --remote-debugging-port), + // in which case webSocketUrl is absent from capabilities and getBidi() throws. + // We degrade gracefully so all non-BiDi tools still work. if (this.consoleEvents) { - await this.consoleEvents.subscribe(undefined); + try { + await this.consoleEvents.subscribe(undefined); + } catch { + logDebug('Console events unavailable (BiDi not supported by this Firefox session)'); + this.consoleEvents = null; + } } if (this.networkEvents) { - await this.networkEvents.subscribe(undefined); + try { + await this.networkEvents.subscribe(undefined); + } catch { + logDebug('Network events unavailable (BiDi not supported by this Firefox session)'); + this.networkEvents = null; + } } } @@ -186,14 +203,18 @@ export class FirefoxClient { async getConsoleMessages(): Promise { if (!this.consoleEvents) { - throw new Error('Console events not available in connect-existing mode (requires BiDi)'); + throw new Error( + 'Console events not available (Firefox Remote Agent not running — start Firefox with --remote-debugging-port to enable BiDi)' + ); } return this.consoleEvents.getMessages(); } clearConsoleMessages(): void { if (!this.consoleEvents) { - throw new Error('Console events not available in connect-existing mode (requires BiDi)'); + throw new Error( + 'Console events not available (Firefox Remote Agent not running — start Firefox with --remote-debugging-port to enable BiDi)' + ); } this.consoleEvents.clearMessages(); } @@ -294,28 +315,36 @@ export class FirefoxClient { async startNetworkMonitoring(): Promise { if (!this.networkEvents) { - throw new Error('Network events not available in connect-existing mode (requires BiDi)'); + throw new Error( + 'Network events not available (Firefox Remote Agent not running — start Firefox with --remote-debugging-port to enable BiDi)' + ); } this.networkEvents.startMonitoring(); } async stopNetworkMonitoring(): Promise { if (!this.networkEvents) { - throw new Error('Network events not available in connect-existing mode (requires BiDi)'); + throw new Error( + 'Network events not available (Firefox Remote Agent not running — start Firefox with --remote-debugging-port to enable BiDi)' + ); } this.networkEvents.stopMonitoring(); } async getNetworkRequests(): Promise { if (!this.networkEvents) { - throw new Error('Network events not available in connect-existing mode (requires BiDi)'); + throw new Error( + 'Network events not available (Firefox Remote Agent not running — start Firefox with --remote-debugging-port to enable BiDi)' + ); } return this.networkEvents.getRequests(); } clearNetworkRequests(): void { if (!this.networkEvents) { - throw new Error('Network events not available in connect-existing mode (requires BiDi)'); + throw new Error( + 'Network events not available (Firefox Remote Agent not running — start Firefox with --remote-debugging-port to enable BiDi)' + ); } this.networkEvents.clearRequests(); } @@ -338,7 +367,7 @@ export class FirefoxClient { return this.snapshot.resolveUidToSelector(uid); } - async resolveUidToElement(uid: string): Promise { + async resolveUidToElement(uid: string): Promise { if (!this.snapshot) { throw new Error('Not connected'); } diff --git a/src/firefox/pages.ts b/src/firefox/pages.ts index 4117b79..f1162b8 100644 --- a/src/firefox/pages.ts +++ b/src/firefox/pages.ts @@ -2,12 +2,12 @@ * Page/Tab/Window management */ -import type { IDriver } from './core.js'; +import { WebDriver } from 'selenium-webdriver'; import { log } from '../utils/logger.js'; export class PageManagement { constructor( - private driver: IDriver, + private driver: WebDriver, private getCurrentContextId: () => string | null, private setCurrentContextId: (id: string) => void ) {} diff --git a/src/firefox/snapshot/manager.ts b/src/firefox/snapshot/manager.ts index e650fa6..9d7a1c9 100644 --- a/src/firefox/snapshot/manager.ts +++ b/src/firefox/snapshot/manager.ts @@ -3,7 +3,7 @@ * Handles snapshot creation using bundled injected script */ -import type { IDriver, IElement } from '../core.js'; +import { WebDriver, WebElement } from 'selenium-webdriver'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,12 +25,12 @@ export interface SnapshotOptions { * Uses bundled injected script for snapshot creation */ export class SnapshotManager { - private driver: IDriver; + private driver: WebDriver; private resolver: UidResolver; private injectedScript: string | null = null; private currentSnapshotId = 0; - constructor(driver: IDriver) { + constructor(driver: WebDriver) { this.driver = driver; this.resolver = new UidResolver(driver); } @@ -159,7 +159,7 @@ export class SnapshotManager { /** * Resolve UID to WebElement (with staleness check and caching) */ - async resolveUidToElement(uid: string): Promise { + async resolveUidToElement(uid: string): Promise { return await this.resolver.resolveUidToElement(uid); } diff --git a/src/firefox/snapshot/resolver.ts b/src/firefox/snapshot/resolver.ts index c64557c..79b8ae3 100644 --- a/src/firefox/snapshot/resolver.ts +++ b/src/firefox/snapshot/resolver.ts @@ -3,14 +3,14 @@ * Handles UID validation, resolution to selectors/elements, and element caching */ -import type { IDriver, IElement } from '../core.js'; +import { By, WebDriver, WebElement } from 'selenium-webdriver'; import { logDebug } from '../../utils/logger.js'; import type { UidEntry } from './types.js'; -interface IElementCacheEntry { +interface WebElementCacheEntry { selector: string; xpath?: string; - cachedElement: IElement; + cachedElement: WebElement; snapshotId: number; timestamp: number; } @@ -21,10 +21,10 @@ interface IElementCacheEntry { */ export class UidResolver { private uidToEntry = new Map(); - private elementCache = new Map(); + private elementCache = new Map(); private currentSnapshotId = 0; - constructor(private driver: IDriver) {} + constructor(private driver: WebDriver) {} /** * Update current snapshot ID @@ -98,7 +98,7 @@ export class UidResolver { * Resolve UID to element (with staleness check and caching) * Tries CSS first, falls back to XPath */ - async resolveUidToElement(uid: string): Promise { + async resolveUidToElement(uid: string): Promise { this.validateUid(uid); const entry = this.uidToEntry.get(uid); @@ -122,7 +122,7 @@ export class UidResolver { // Try CSS selector first try { - const element = await this.driver.findElement({ using: 'css selector', value: entry.css }); + const element = await this.driver.findElement(By.css(entry.css)); // Update cache this.elementCache.set(uid, { @@ -142,7 +142,7 @@ export class UidResolver { const xpathSelector = entry.xpath; if (xpathSelector) { try { - const element = await this.driver.findElement({ using: 'xpath', value: xpathSelector }); + const element = await this.driver.findElement(By.xpath(xpathSelector)); // Update cache this.elementCache.set(uid, { diff --git a/src/firefox/snapshot/types.ts b/src/firefox/snapshot/types.ts index 538ea4e..d502709 100644 --- a/src/firefox/snapshot/types.ts +++ b/src/firefox/snapshot/types.ts @@ -2,7 +2,7 @@ * Snapshot types and interfaces */ -import type { IElement } from '../core.js'; +import type { WebElement } from 'selenium-webdriver'; /** * UID entry with CSS and XPath selectors @@ -100,7 +100,7 @@ export interface InjectedScriptResult { export interface ElementCacheEntry { selector: string; xpath?: string; - cachedElement?: IElement; + cachedElement?: WebElement; snapshotId: number; timestamp: number; } diff --git a/src/firefox/types.ts b/src/firefox/types.ts index 8ee1c38..26e6856 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -60,7 +60,6 @@ export interface FirefoxLaunchOptions { acceptInsecureCerts?: boolean | undefined; connectExisting?: boolean | undefined; marionettePort?: number | undefined; - marionetteHost?: string | undefined; env?: Record | undefined; logFile?: string | undefined; /** Firefox preferences to set at startup via moz:firefoxOptions */ diff --git a/src/index.ts b/src/index.ts index 9fc03c6..e05b3b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -141,7 +141,6 @@ export async function getFirefox(): Promise { acceptInsecureCerts: args.acceptInsecureCerts, connectExisting: args.connectExisting, marionettePort: args.marionettePort, - marionetteHost: args.marionetteHost, env: envVars, logFile: args.outputFile ?? undefined, prefs, @@ -154,7 +153,13 @@ export async function getFirefox(): Promise { log('Firefox DevTools connection established'); return firefox; } catch (error) { - // Connection failed, clean up the failed instance + // Clean up before discarding — ensures the geckodriver process is killed + // and the Marionette session is released. Without this, a failure during + // BiDi setup (after the WebDriver session is already established) would + // leave geckodriver running with an active Marionette session, causing + // "Connection attempt denied because an active session has been found" + // on the next connect attempt. + await firefox.close().catch(() => {}); firefox = null; throw error; } diff --git a/tests/firefox/connect-existing.test.ts b/tests/firefox/connect-existing.test.ts index 3cab677..664360a 100644 --- a/tests/firefox/connect-existing.test.ts +++ b/tests/firefox/connect-existing.test.ts @@ -1,431 +1,8 @@ /** - * Unit tests for connect-existing mode features (PR #50) - * - GeckodriverHttpDriver BiDi support - * - Session cleanup on quit/kill - * - marionetteHost parameter - * - Reconnect on lost connection + * Tests for connect-existing mode (FirefoxCore behaviour) */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// --------------------------------------------------------------------------- -// GeckodriverHttpDriver tests — we access the class indirectly through -// FirefoxCore since GeckodriverHttpDriver is not exported. -// For direct testing we use (core as any).driver after mocked connect(). -// --------------------------------------------------------------------------- - -describe('GeckodriverHttpDriver BiDi support', () => { - let mockWsInstance: { - on: ReturnType; - off: ReturnType; - send: ReturnType; - close: ReturnType; - readyState: number; - }; - let wsEventListeners: Record; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - - wsEventListeners = {}; - mockWsInstance = { - readyState: 1, - on: vi.fn((event: string, handler: Function) => { - if (!wsEventListeners[event]) wsEventListeners[event] = []; - wsEventListeners[event].push(handler); - }), - off: vi.fn(), - send: vi.fn(), - close: vi.fn(), - }; - }); - - /** - * Helper: create a GeckodriverHttpDriver instance via mocked connect(). - * Returns the FirefoxCore with driver set to GeckodriverHttpDriver. - */ - async function createConnectExistingCore(opts?: { - webSocketUrl?: string; - marionetteHost?: string; - }) { - const mockGdProcess = { - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn(), - kill: vi.fn(), - }; - - // Mock child_process.spawn to return our mock geckodriver process - vi.doMock('node:child_process', async (importOriginal) => { - const original = (await importOriginal()) as typeof import('node:child_process'); - return { - ...original, - spawn: vi.fn(() => { - // Simulate geckodriver printing its listening port - setTimeout(() => { - const onData = mockGdProcess.stderr.on.mock.calls.find( - (c: unknown[]) => c[0] === 'data' - ); - if (onData) { - (onData[1] as Function)(Buffer.from('Listening on 127.0.0.1:4444')); - } - }, 5); - return mockGdProcess; - }), - }; - }); - - // Mock fetch for session creation - const wsUrl = opts?.webSocketUrl ?? null; - vi.doMock('node:module', async (importOriginal) => await importOriginal()); - - // We need to mock global fetch - const mockFetch = vi.fn().mockResolvedValue({ - json: vi.fn().mockResolvedValue({ - value: { - sessionId: 'mock-session-id', - capabilities: { - webSocketUrl: wsUrl, - }, - }, - }), - }); - vi.stubGlobal('fetch', mockFetch); - - // Mock selenium-manager to avoid real binary lookup - vi.doMock('selenium-webdriver/package.json', () => ({}), { virtual: true }); - - // Mock WebSocket constructor - vi.doMock('ws', () => ({ - default: vi.fn(() => { - // Simulate open event on next tick - setTimeout(() => { - if (wsEventListeners['open']) { - wsEventListeners['open'].forEach((h) => h()); - } - }, 5); - return mockWsInstance; - }), - })); - - const { FirefoxCore } = await import('@/firefox/core.js'); - - const core = new FirefoxCore({ - headless: true, - connectExisting: true, - marionettePort: 2828, - marionetteHost: opts?.marionetteHost, - }); - - await core.connect(); - return { core, mockGdProcess, mockFetch }; - } - - it('should throw when getBidi() called without webSocketUrl', async () => { - const { core } = await createConnectExistingCore({ webSocketUrl: undefined }); - const driver = core.getDriver(); - - await expect(driver.getBidi()).rejects.toThrow(/BiDi is not available.*webSocketUrl/); - }); - - it('should open WebSocket and return BiDi handle', async () => { - const { core } = await createConnectExistingCore({ - webSocketUrl: 'ws://127.0.0.1:9222/session/test', - }); - const driver = core.getDriver(); - - const bidi = await driver.getBidi(); - expect(bidi).toBeDefined(); - expect(bidi.socket).toBeDefined(); - expect(bidi.subscribe).toBeDefined(); - }); - - it('should cache BiDi connection on subsequent calls', async () => { - const { core } = await createConnectExistingCore({ - webSocketUrl: 'ws://127.0.0.1:9222/session/test', - }); - const driver = core.getDriver(); - - const bidi1 = await driver.getBidi(); - const bidi2 = await driver.getBidi(); - expect(bidi1).toBe(bidi2); - }); - - it('subscribe should send session.subscribe and wait for response', async () => { - const { core } = await createConnectExistingCore({ - webSocketUrl: 'ws://127.0.0.1:9222/session/test', - }); - const driver = core.getDriver(); - const bidi = await driver.getBidi(); - - // Start subscribe - const subscribePromise = bidi.subscribe!('log.entryAdded', ['context-1']); - - // Wait a tick for send to be called - await new Promise((r) => setTimeout(r, 10)); - - expect(mockWsInstance.send).toHaveBeenCalledTimes(1); - const sent = JSON.parse(mockWsInstance.send.mock.calls[0][0]); - expect(sent.method).toBe('session.subscribe'); - expect(sent.params.events).toEqual(['log.entryAdded']); - expect(sent.params.contexts).toEqual(['context-1']); - - // Simulate response - if (wsEventListeners['message']) { - wsEventListeners['message'].forEach((h) => h(JSON.stringify({ id: sent.id, result: {} }))); - } - - await expect(subscribePromise).resolves.toBeUndefined(); - }); - - it('subscribe should reject on error response', async () => { - const { core } = await createConnectExistingCore({ - webSocketUrl: 'ws://127.0.0.1:9222/session/test', - }); - const driver = core.getDriver(); - const bidi = await driver.getBidi(); - - const subscribePromise = bidi.subscribe!('log.entryAdded'); - - await new Promise((r) => setTimeout(r, 10)); - - const sent = JSON.parse(mockWsInstance.send.mock.calls[0][0]); - if (wsEventListeners['message']) { - wsEventListeners['message'].forEach((h) => - h(JSON.stringify({ id: sent.id, error: 'invalid subscription' })) - ); - } - - await expect(subscribePromise).rejects.toThrow(/BiDi subscribe error/); - }); -}); - -describe('GeckodriverHttpDriver session cleanup', () => { - let mockGdProcess: { - stdout: { on: ReturnType }; - stderr: { on: ReturnType }; - on: ReturnType; - kill: ReturnType; - }; - let mockFetch: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - - mockGdProcess = { - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn(), - kill: vi.fn(), - }; - - vi.doMock('node:child_process', async (importOriginal) => { - const original = (await importOriginal()) as typeof import('node:child_process'); - return { - ...original, - spawn: vi.fn(() => { - setTimeout(() => { - const onData = mockGdProcess.stderr.on.mock.calls.find( - (c: unknown[]) => c[0] === 'data' - ); - if (onData) { - (onData[1] as Function)(Buffer.from('Listening on 127.0.0.1:4444')); - } - }, 5); - return mockGdProcess; - }), - }; - }); - - mockFetch = vi.fn().mockResolvedValue({ - json: vi.fn().mockResolvedValue({ - value: { - sessionId: 'mock-session-id', - capabilities: {}, - }, - }), - }); - vi.stubGlobal('fetch', mockFetch); - - vi.doMock('ws', () => ({ default: vi.fn() })); - }); - - async function createCore() { - const { FirefoxCore } = await import('@/firefox/core.js'); - const core = new FirefoxCore({ - headless: true, - connectExisting: true, - marionettePort: 2828, - }); - await core.connect(); - return core; - } - - it('kill() should send DELETE /session before killing geckodriver', async () => { - const core = await createCore(); - - // Mock fetch for the DELETE call - mockFetch.mockResolvedValueOnce({ - json: vi.fn().mockResolvedValue({ value: null }), - }); - - const driver = core.getDriver() as any; - await driver.kill(); - - // Verify DELETE /session was called - const deleteCalls = mockFetch.mock.calls.filter( - (c: unknown[]) => typeof c[1] === 'object' && (c[1] as RequestInit).method === 'DELETE' - ); - expect(deleteCalls.length).toBeGreaterThan(0); - expect(mockGdProcess.kill).toHaveBeenCalled(); - }); - - it('quit() should send DELETE /session and kill geckodriver', async () => { - const core = await createCore(); - - mockFetch.mockResolvedValueOnce({ - json: vi.fn().mockResolvedValue({ value: null }), - }); - - const driver = core.getDriver() as any; - await driver.quit(); - - const deleteCalls = mockFetch.mock.calls.filter( - (c: unknown[]) => typeof c[1] === 'object' && (c[1] as RequestInit).method === 'DELETE' - ); - expect(deleteCalls.length).toBeGreaterThan(0); - expect(mockGdProcess.kill).toHaveBeenCalled(); - }); - - it('kill() should not throw if DELETE /session fails', async () => { - const core = await createCore(); - - mockFetch.mockRejectedValueOnce(new Error('connection refused')); - - const driver = core.getDriver() as any; - await expect(driver.kill()).resolves.toBeUndefined(); - expect(mockGdProcess.kill).toHaveBeenCalled(); - }); -}); - -describe('GeckodriverElement W3C serialization', () => { - const W3C_ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf'; - - let mockFetch: ReturnType; - let fetchCallBodies: unknown[]; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - - fetchCallBodies = []; - - const mockGdProcess = { - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn(), - kill: vi.fn(), - }; - - vi.doMock('node:child_process', async (importOriginal) => { - const original = (await importOriginal()) as typeof import('node:child_process'); - return { - ...original, - spawn: vi.fn(() => { - setTimeout(() => { - const onData = mockGdProcess.stderr.on.mock.calls.find( - (c: unknown[]) => c[0] === 'data' - ); - if (onData) { - (onData[1] as Function)(Buffer.from('Listening on 127.0.0.1:4444')); - } - }, 5); - return mockGdProcess; - }), - }; - }); - - mockFetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { - if (opts?.body) { - fetchCallBodies.push(JSON.parse(opts.body as string)); - } - // Return W3C element reference for /element endpoint, session info otherwise - const isElementRequest = url.endsWith('/element'); - return Promise.resolve({ - json: () => - Promise.resolve({ - value: isElementRequest - ? { [W3C_ELEMENT_KEY]: 'found-element-id' } - : { sessionId: 'mock-session-id', capabilities: {} }, - }), - }); - }); - vi.stubGlobal('fetch', mockFetch); - vi.doMock('ws', () => ({ default: vi.fn() })); - }); - - async function createDriver() { - const { FirefoxCore } = await import('@/firefox/core.js'); - const core = new FirefoxCore({ - headless: true, - connectExisting: true, - marionettePort: 2828, - }); - await core.connect(); - return core.getDriver(); - } - - it('findElement should return element that serializes to W3C format', async () => { - const driver = await createDriver(); - const el = await driver.findElement({ using: 'css selector', value: '#test' }); - - const json = JSON.parse(JSON.stringify(el)); - expect(json).toEqual({ [W3C_ELEMENT_KEY]: 'found-element-id' }); - }); - - it('executeScript should send W3C element reference in args', async () => { - const driver = await createDriver(); - const el = await driver.findElement({ using: 'css selector', value: '#test' }); - - fetchCallBodies = []; - await driver.executeScript('arguments[0].scrollIntoView()', el); - - const execBody = fetchCallBodies.find( - (b: any) => b.script === 'arguments[0].scrollIntoView()' - ) as any; - expect(execBody).toBeDefined(); - expect(execBody.args[0]).toEqual({ [W3C_ELEMENT_KEY]: 'found-element-id' }); - }); - - it('actions().move({ origin: el }) should send W3C element reference', async () => { - const driver = await createDriver(); - const el = await driver.findElement({ using: 'css selector', value: '#test' }); - - fetchCallBodies = []; - await driver.actions({ async: true }).move({ origin: el }).perform(); - - const actionsBody = fetchCallBodies.find((b: any) => b.actions) as any; - expect(actionsBody).toBeDefined(); - const moveAction = actionsBody.actions[0].actions[0]; - expect(moveAction.origin).toEqual({ [W3C_ELEMENT_KEY]: 'found-element-id' }); - }); -}); - -describe('FirefoxCore connect-existing with marionetteHost', () => { - it('should pass marionetteHost to options', async () => { - const { FirefoxCore } = await import('@/firefox/core.js'); - const core = new FirefoxCore({ - headless: true, - connectExisting: true, - marionettePort: 2828, - marionetteHost: '192.168.1.100', - }); - - expect(core.getOptions().marionetteHost).toBe('192.168.1.100'); - }); -}); +import { describe, it, expect, vi } from 'vitest'; describe('getFirefox() reconnect behavior', () => { it('should reconnect when connection is lost instead of throwing', async () => {