From 0543a91dd9239767c33b046ed4266d39d72524af Mon Sep 17 00:00:00 2001 From: Paul Adenot Date: Fri, 9 Jan 2026 15:36:19 +0100 Subject: [PATCH 01/20] feat: add env vars and output capture, dynamic firefox (re)start - CLI: --env and --output-file flags - Capture stdout/stderr via fd redirection - Tools: get_firefox_output, get_firefox_info, restart_firefox - Change Firefox config at runtime without MCP server restart: different binary, env var, etc. --- package-lock.json | 4 +- src/cli.ts | 10 ++ src/firefox/core.ts | 86 ++++++++++ src/firefox/index.ts | 14 ++ src/firefox/types.ts | 2 + src/index.ts | 65 ++++++-- src/tools/firefox-management.ts | 282 ++++++++++++++++++++++++++++++++ src/tools/index.ts | 10 ++ 8 files changed, 460 insertions(+), 13 deletions(-) create mode 100644 src/tools/firefox-management.ts diff --git a/package-lock.json b/package-lock.json index 2bfe1f8..f47da31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "firefox-devtools-mcp", - "version": "0.7.4", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firefox-devtools-mcp", - "version": "0.7.4", + "version": "0.8.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.1", diff --git a/src/cli.ts b/src/cli.ts index f1b4dad..d9ede30 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -66,6 +66,16 @@ export const cliOptions = { description: 'Marionette port to connect to when using --connect-existing (default: 2828)', default: Number(process.env.MARIONETTE_PORT ?? '2828'), }, + env: { + type: 'array', + description: + 'Environment variables for Firefox in KEY=VALUE format. Can be specified multiple times. Example: --env MOZ_LOG=HTMLMediaElement:4', + }, + outputFile: { + type: 'string', + description: + 'Path to file where Firefox output (stdout/stderr) will be written. If not specified, output is written to ~/.firefox-devtools-mcp/output/', + }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { diff --git a/src/firefox/core.ts b/src/firefox/core.ts index 0c68d86..ddd8cf8 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -5,6 +5,9 @@ import { Builder, Browser } 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 type { FirefoxLaunchOptions } from './types.js'; import { log, logDebug } from '../utils/logger.js'; @@ -463,6 +466,9 @@ function findGeckodriverInCache( export class FirefoxCore { private driver: IDriver | null = null; private currentContextId: string | null = null; + private originalEnv: Record = {}; + private logFilePath: string | undefined; + private logFileFd: number | undefined; constructor(private options: FirefoxLaunchOptions) {} @@ -483,6 +489,31 @@ export class FirefoxCore { const port = this.options.marionettePort ?? 2828; this.driver = await GeckodriverHttpDriver.connect(port); } else { + // Set up output file for capturing Firefox stdout/stderr + if (this.options.logFile) { + this.logFilePath = this.options.logFile; + } else if (this.options.env && Object.keys(this.options.env).length > 0) { + const outputDir = join(homedir(), '.firefox-devtools-mcp', 'output'); + mkdirSync(outputDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + this.logFilePath = join(outputDir, `firefox-${timestamp}.log`); + } + + // Set environment variables (will be inherited by geckodriver -> Firefox) + if (this.options.env) { + for (const [key, value] of Object.entries(this.options.env)) { + this.originalEnv[key] = process.env[key]; + process.env[key] = value; + logDebug(`Set env ${key}=${value}`); + } + + // Important: Do NOT set MOZ_LOG_FILE - MOZ_LOG writes to stderr by default + // We capture stderr directly through file descriptor redirection + if (this.options.env.MOZ_LOG_FILE) { + logDebug('Note: MOZ_LOG_FILE in env will be used, but may be blocked by sandbox'); + } + } + // Standard path: launch a new Firefox via selenium-webdriver const firefoxOptions = new firefox.Options(); firefoxOptions.enableBidi(); @@ -509,10 +540,27 @@ export class FirefoxCore { firefoxOptions.setAcceptInsecureCerts(true); } + // Configure geckodriver service to capture output + const serviceBuilder = new firefox.ServiceBuilder(); + + // If we have a log file, open it and redirect geckodriver output there + // This captures both geckodriver logs and Firefox stderr (including MOZ_LOG) + if (this.logFilePath) { + // Open file for appending, create if doesn't exist + this.logFileFd = openSync(this.logFilePath, 'a'); + + // Configure stdio: stdin=ignore, stdout=logfile, stderr=logfile + // This redirects all output from geckodriver and Firefox to the log file + serviceBuilder.setStdio(['ignore', this.logFileFd, this.logFileFd]); + + log(`📝 Capturing Firefox output to: ${this.logFilePath}`); + } + // selenium WebDriver satisfies IDriver structurally at runtime this.driver = (await new Builder() .forBrowser(Browser.FIREFOX) .setFirefoxOptions(firefoxOptions) + .setFirefoxService(serviceBuilder) .build()) as unknown as IDriver; } @@ -589,6 +637,20 @@ export class FirefoxCore { this.currentContextId = contextId; } + /** + * Get log file path + */ + getLogFilePath(): string | undefined { + return this.logFilePath; + } + + /** + * Get current launch options + */ + getOptions(): FirefoxLaunchOptions { + return this.options; + } + /** * Close driver and cleanup. * When connected to an existing Firefox instance, only kills geckodriver @@ -603,6 +665,30 @@ export class FirefoxCore { } this.driver = null; } + + // Close log file descriptor if open + if (this.logFileFd !== undefined) { + try { + closeSync(this.logFileFd); + logDebug('Log file closed'); + } catch (error) { + logDebug( + `Error closing log file: ${error instanceof Error ? error.message : String(error)}` + ); + } + this.logFileFd = undefined; + } + + // Restore original environment variables + for (const [key, value] of Object.entries(this.originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + this.originalEnv = {}; + log('✅ Firefox DevTools closed'); } } diff --git a/src/firefox/index.ts b/src/firefox/index.ts index 5df9e1c..605da92 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -390,6 +390,20 @@ export class FirefoxClient { return await this.core.isConnected(); } + /** + * Get log file path (if logging is enabled) + */ + getLogFilePath(): string | undefined { + return this.core.getLogFilePath(); + } + + /** + * Get current launch options + */ + getOptions(): FirefoxLaunchOptions { + return this.core.getOptions(); + } + /** * Reset all internal state (used when Firefox is detected as closed) */ diff --git a/src/firefox/types.ts b/src/firefox/types.ts index 688b62d..2df38de 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -60,6 +60,8 @@ export interface FirefoxLaunchOptions { acceptInsecureCerts?: boolean | undefined; connectExisting?: boolean | undefined; marionettePort?: number | undefined; + env?: Record | undefined; + logFile?: string | undefined; } /** diff --git a/src/index.ts b/src/index.ts index c07225b..815de1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ export const args = parseArguments(SERVER_VERSION); // Global context (lazy initialized on first tool call) let firefox: FirefoxDevTools | null = null; +let nextLaunchOptions: FirefoxLaunchOptions | null = null; /** * Reset Firefox instance (used when disconnection is detected) @@ -69,6 +70,15 @@ export function resetFirefox(): void { log('Firefox instance reset - will reconnect on next tool call'); } +/** + * Set options for the next Firefox launch + * Used by restart_firefox tool to change configuration + */ +export function setNextLaunchOptions(options: FirefoxLaunchOptions): void { + nextLaunchOptions = options; + log('Next launch options updated'); +} + export async function getFirefox(): Promise { // If we have an existing instance, verify it's still connected if (firefox) { @@ -84,17 +94,40 @@ export async function getFirefox(): Promise { // No existing instance - create new connection log('Initializing Firefox DevTools connection...'); - const options: FirefoxLaunchOptions = { - firefoxPath: args.firefoxPath ?? undefined, - headless: args.headless, - profilePath: args.profilePath ?? undefined, - viewport: args.viewport ?? undefined, - args: (args.firefoxArg as string[] | undefined) ?? undefined, - startUrl: args.startUrl ?? undefined, - acceptInsecureCerts: args.acceptInsecureCerts, - connectExisting: args.connectExisting, - marionettePort: args.marionettePort, - }; + let options: FirefoxLaunchOptions; + + // Use nextLaunchOptions if set (from restart_firefox tool) + if (nextLaunchOptions) { + options = nextLaunchOptions; + nextLaunchOptions = null; // Clear after use + log('Using custom launch options from restart_firefox'); + } else { + // Parse environment variables from CLI args (format: KEY=VALUE) + let envVars: Record | undefined; + if (args.env && Array.isArray(args.env) && args.env.length > 0) { + envVars = {}; + for (const envStr of args.env as string[]) { + const [key, ...valueParts] = envStr.split('='); + if (key && valueParts.length > 0) { + envVars[key] = valueParts.join('='); + } + } + } + + options = { + firefoxPath: args.firefoxPath ?? undefined, + headless: args.headless, + profilePath: args.profilePath ?? undefined, + viewport: args.viewport ?? undefined, + args: (args.firefoxArg as string[] | undefined) ?? undefined, + startUrl: args.startUrl ?? undefined, + acceptInsecureCerts: args.acceptInsecureCerts, + connectExisting: args.connectExisting, + marionettePort: args.marionettePort, + env: envVars, + logFile: args.outputFile ?? undefined, + }; + } firefox = new FirefoxDevTools(options); await firefox.connect(); @@ -145,6 +178,11 @@ const toolHandlers = new Map Promise since) { + return successResponse( + `Output file is ${Math.floor(ageSeconds)}s old, but only output from last ${since}s was requested. File may not have recent entries.` + ); + } + } + + // Read output file + const content = readFileSync(logFilePath, 'utf-8'); + let allLines = content.split('\n').filter((line) => line.trim().length > 0); + + // Apply grep filter + if (grep) { + const grepLower = grep.toLowerCase(); + allLines = allLines.filter((line) => line.toLowerCase().includes(grepLower)); + } + + // Get last N lines + const maxLines = Math.min(lines, 10000); + const recentLines = allLines.slice(-maxLines); + + const result = [ + `📋 Firefox Output File: ${logFilePath}`, + `Total lines in file: ${allLines.length}`, + grep ? `Lines matching "${grep}": ${allLines.length}` : '', + `Showing last ${recentLines.length} lines:`, + '', + '─'.repeat(80), + recentLines.join('\n'), + ] + .filter(Boolean) + .join('\n'); + + return successResponse(result); + } catch (error) { + return errorResponse(error as Error); + } +} + +// ============================================================================ +// Tool: get_firefox_info +// ============================================================================ + +export const getFirefoxInfoTool = { + name: 'get_firefox_info', + description: + 'Get information about the current Firefox instance configuration, including binary path, environment variables, and output file location.', + inputSchema: { + type: 'object', + properties: {}, + }, +}; + +export async function handleGetFirefoxInfo(_input: unknown) { + try { + const firefox = await getFirefox(); + const options = firefox.getOptions(); + const logFilePath = firefox.getLogFilePath(); + + const info = []; + info.push('🩊 Firefox Instance Configuration'); + info.push(''); + + info.push(`Binary: ${options.firefoxPath ?? 'System Firefox (default)'}`); + info.push(`Headless: ${options.headless ? 'Yes' : 'No'}`); + + if (options.viewport) { + info.push(`Viewport: ${options.viewport.width}x${options.viewport.height}`); + } + + if (options.profilePath) { + info.push(`Profile: ${options.profilePath}`); + } + + if (options.startUrl) { + info.push(`Start URL: ${options.startUrl}`); + } + + if (options.args && options.args.length > 0) { + info.push(`Arguments: ${options.args.join(' ')}`); + } + + if (options.env && Object.keys(options.env).length > 0) { + info.push(''); + info.push('Environment Variables:'); + for (const [key, value] of Object.entries(options.env)) { + info.push(` ${key}=${value}`); + } + } + + if (logFilePath) { + info.push(''); + info.push(`Output File: ${logFilePath}`); + if (existsSync(logFilePath)) { + const stats = statSync(logFilePath); + const sizeMB = (stats.size / 1024 / 1024).toFixed(2); + info.push(` Size: ${sizeMB} MB`); + info.push(` Last Modified: ${stats.mtime.toISOString()}`); + } else { + info.push(' (file not created yet)'); + } + } + + return successResponse(info.join('\n')); + } catch (error) { + return errorResponse(error as Error); + } +} + +// ============================================================================ +// Tool: restart_firefox +// ============================================================================ + +export const restartFirefoxTool = { + name: 'restart_firefox', + description: + 'Restart Firefox with different configuration. Allows changing binary path, environment variables, and other options. All current tabs will be closed.', + inputSchema: { + type: 'object', + properties: { + firefoxPath: { + type: 'string', + description: 'New Firefox binary path (optional, keeps current if not specified)', + }, + env: { + type: 'array', + items: { + type: 'string', + }, + description: + 'New environment variables in KEY=VALUE format (optional, e.g., ["MOZ_LOG=HTMLMediaElement:5", "MOZ_LOG_FILE=/tmp/ff.log"])', + }, + headless: { + type: 'boolean', + description: 'Run in headless mode (optional, keeps current if not specified)', + }, + startUrl: { + type: 'string', + description: + 'URL to navigate to after restart (optional, uses about:home if not specified)', + }, + }, + }, +}; + +export async function handleRestartFirefox(input: unknown) { + try { + const { firefoxPath, env, headless, startUrl } = input as { + firefoxPath?: string; + env?: string[]; + headless?: boolean; + startUrl?: string; + }; + + // Get current Firefox instance to retrieve current options + const currentFirefox = await getFirefox(); + const currentOptions = currentFirefox.getOptions(); + + // Parse new environment variables + let newEnv: Record | undefined; + if (env && Array.isArray(env) && env.length > 0) { + newEnv = {}; + for (const envStr of env) { + const [key, ...valueParts] = envStr.split('='); + if (key && valueParts.length > 0) { + newEnv[key] = valueParts.join('='); + } + } + } + + // Merge with current options, preferring new values + const newOptions = { + ...currentOptions, + firefoxPath: firefoxPath ?? currentOptions.firefoxPath, + env: newEnv !== undefined ? newEnv : currentOptions.env, + headless: headless !== undefined ? headless : currentOptions.headless, + startUrl: startUrl ?? currentOptions.startUrl ?? 'about:home', + }; + + // Set options for next launch + setNextLaunchOptions(newOptions); + + // Close current instance + await currentFirefox.close(); + resetFirefox(); + + // Prepare change summary + const changes = []; + if (firefoxPath && firefoxPath !== currentOptions.firefoxPath) { + changes.push(`Binary: ${firefoxPath}`); + } + if (newEnv !== undefined && JSON.stringify(newEnv) !== JSON.stringify(currentOptions.env)) { + changes.push(`Environment variables updated:`); + for (const [key, value] of Object.entries(newEnv)) { + changes.push(` ${key}=${value}`); + } + } + if (headless !== undefined && headless !== currentOptions.headless) { + changes.push(`Headless: ${headless ? 'enabled' : 'disabled'}`); + } + if (startUrl && startUrl !== currentOptions.startUrl) { + changes.push(`Start URL: ${startUrl}`); + } + + if (changes.length === 0) { + return successResponse( + '✅ Firefox closed. Will restart with same configuration on next tool call.' + ); + } + + return successResponse( + `✅ Firefox closed. Will restart with new configuration on next tool call:\n${changes.join('\n')}` + ); + } catch (error) { + return errorResponse(error as Error); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 09ce596..b3fa4ee 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -80,3 +80,13 @@ export { handleNavigateHistory, handleSetViewportSize, } from './utilities.js'; + +// Firefox management tools (logs, restart, info) +export { + getFirefoxLogsTool, + getFirefoxInfoTool, + restartFirefoxTool, + handleGetFirefoxLogs, + handleGetFirefoxInfo, + handleRestartFirefox, +} from './firefox-management.js'; From 826323eb996e787a0a0c701bf7097014c5727202 Mon Sep 17 00:00:00 2001 From: Paul Adenot Date: Mon, 12 Jan 2026 16:14:49 +0100 Subject: [PATCH 02/20] Enable content script evaluation --- README.md | 1 + src/index.ts | 8 ++++---- src/tools/index.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4196ea6..aeab96e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ BiDi-dependent features (console events, network events) are not available in co - Network: list/get (ID‑first, filters, always‑on capture) - Console: list/clear - Screenshot: page/by uid (with optional `saveTo` for CLI environments) +- Script: evaluate_script (content) - Utilities: accept/dismiss dialog, history back/forward, set viewport ### Screenshot optimization for Claude Code diff --git a/src/index.ts b/src/index.ts index 815de1e..b461557 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,8 +145,8 @@ const toolHandlers = new Map Promise Date: Mon, 12 Jan 2026 16:14:59 +0100 Subject: [PATCH 03/20] Allow working in chrome context This implements: - list_chrome_context - select_chrome_context - evaluate_chrome_script They do what they say on the tin, but this requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`, as expected. This allows some functionnality for Firefox javascript developers. --- README.md | 3 +- src/firefox/core.ts | 47 +++++++++++ src/firefox/index.ts | 8 ++ src/index.ts | 10 +++ src/tools/chrome-context.ts | 155 ++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 10 +++ 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/tools/chrome-context.ts diff --git a/README.md b/README.md index aeab96e..34f7b0b 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ BiDi-dependent features (console events, network events) are not available in co - Network: list/get (ID‑first, filters, always‑on capture) - Console: list/clear - Screenshot: page/by uid (with optional `saveTo` for CLI environments) -- Script: evaluate_script (content) +- Script: evaluate_script (content), evaluate_chrome_script (privileged) +- Chrome Context: list/select chrome contexts (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) - Utilities: accept/dismiss dialog, history back/forward, set viewport ### Screenshot optimization for Claude Code diff --git a/src/firefox/core.ts b/src/firefox/core.ts index ddd8cf8..af328cf 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -651,6 +651,53 @@ export class FirefoxCore { return this.options; } + /** + * Send raw BiDi command and get response + */ + async sendBiDiCommand(method: string, params: Record = {}): Promise { + if (!this.driver) { + throw new Error('Driver not connected'); + } + + const bidi = await this.driver.getBidi(); + const id = Math.floor(Math.random() * 1000000); + + return new Promise((resolve, reject) => { + const ws: any = bidi.socket; + + const messageHandler = (data: any) => { + try { + const payload = JSON.parse(data.toString()); + if (payload.id === id) { + ws.off('message', messageHandler); + if (payload.error) { + reject(new Error(`BiDi error: ${JSON.stringify(payload.error)}`)); + } else { + resolve(payload.result); + } + } + } catch (err) { + // ignore parse errors + } + }; + + ws.on('message', messageHandler); + + const command = { + id, + method, + params, + }; + + ws.send(JSON.stringify(command)); + + setTimeout(() => { + ws.off('message', messageHandler); + reject(new Error(`BiDi command timeout: ${method}`)); + }, 10000); + }); + } + /** * Close driver and cleanup. * When connected to an existing Firefox instance, only kills geckodriver diff --git a/src/firefox/index.ts b/src/firefox/index.ts index 605da92..25d749b 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -374,6 +374,14 @@ export class FirefoxClient { // Internal / Advanced // ============================================================================ + /** + * Send raw BiDi command (for advanced operations) + * @internal + */ + async sendBiDiCommand(method: string, params: Record = {}): Promise { + return await this.core.sendBiDiCommand(method, params); + } + /** * Get WebDriver instance (for advanced operations) * @internal diff --git a/src/index.ts b/src/index.ts index b461557..afb6f2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -183,6 +183,11 @@ const toolHandlers = new Map Promise { + try { + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + + const contexts = result.contexts || []; + + return successResponse(formatContextList(contexts)); + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + return errorResponse( + new Error( + 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' + ) + ); + } + return errorResponse(error as Error); + } +} + +export async function handleSelectChromeContext(args: unknown): Promise { + try { + const { contextId } = args as { contextId: string }; + + if (!contextId || typeof contextId !== 'string') { + throw new Error('contextId parameter is required and must be a string'); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + const driver = firefox.getDriver(); + await driver.switchTo().window(contextId); + + try { + await (driver as any).setContext('chrome'); + } catch (contextError) { + return errorResponse( + new Error( + `Switched to context ${contextId} but failed to set Marionette chrome context. Your Firefox build may not support chrome context or MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is not set.` + ) + ); + } + + return successResponse(`✅ Switched to chrome context: ${contextId} (Marionette context set to chrome)`); + } catch (error) { + return errorResponse(error as Error); + } +} + +export async function handleEvaluateChromeScript(args: unknown): Promise { + try { + const { expression } = args as { expression: string }; + + if (!expression || typeof expression !== 'string') { + throw new Error('expression parameter is required and must be a string'); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + const driver = firefox.getDriver(); + + try { + const result = await driver.executeScript(`return (${expression});`); + const resultText = + typeof result === 'string' + ? result + : result === null + ? 'null' + : result === undefined + ? 'undefined' + : JSON.stringify(result, null, 2); + + return successResponse(`🔧 Result:\n${resultText}`); + } catch (executeError) { + return errorResponse( + new Error( + `Script execution failed: ${executeError instanceof Error ? executeError.message : String(executeError)}` + ) + ); + } + } catch (error) { + return errorResponse(error as Error); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 5a84c27..750b2e2 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -90,3 +90,13 @@ export { handleGetFirefoxInfo, handleRestartFirefox, } from './firefox-management.js'; + +// Chrome context tools (privileged JavaScript access) +export { + listChromeContextsTool, + selectChromeContextTool, + evaluateChromeScriptTool, + handleListChromeContexts, + handleSelectChromeContext, + handleEvaluateChromeScript, +} from './chrome-context.js'; From 1cae9bfc16d8def0a3172638a7ee3ba9ad1101f7 Mon Sep 17 00:00:00 2001 From: Paul Adenot Date: Fri, 16 Jan 2026 17:57:45 +0100 Subject: [PATCH 04/20] Handle disconnected Firefox in restart_firefox tool Check connection status before restart to avoid errors when Firefox was closed externally. Fall back to CLI firefoxPath if not provided. Clean up stale instances and update error messages to guide recovery. --- package-lock.json | 4 +- package.json | 12 +-- src/index.ts | 27 +++++- src/tools/chrome-context.ts | 6 +- src/tools/firefox-management.ts | 143 +++++++++++++++++++++++--------- src/utils/errors.ts | 7 +- tests/utils/errors.test.ts | 6 +- 7 files changed, 145 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index f47da31..8c43d59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "firefox-devtools-mcp", + "name": "@padenot/firefox-devtools-mcp", "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "firefox-devtools-mcp", + "name": "@padenot/firefox-devtools-mcp", "version": "0.8.1", "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fd8e69e..546f879 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "firefox-devtools-mcp", + "name": "@padenot/firefox-devtools-mcp", "version": "0.8.1", - "description": "Model Context Protocol (MCP) server for Firefox DevTools automation", - "author": "freema", + "description": "Model Context Protocol (MCP) server for Firefox DevTools automation (fork with Firefox management tools)", + "author": "padenot", "license": "MIT", "type": "module", "main": "dist/index.js", @@ -95,12 +95,12 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/freema/firefox-devtools-mcp.git" + "url": "git+https://github.com/padenot/firefox-devtools-mcp.git" }, "bugs": { - "url": "https://github.com/freema/firefox-devtools-mcp/issues" + "url": "https://github.com/padenot/firefox-devtools-mcp/issues" }, - "homepage": "https://github.com/freema/firefox-devtools-mcp#readme", + "homepage": "https://github.com/padenot/firefox-devtools-mcp#readme", "publishConfig": { "access": "public" } diff --git a/src/index.ts b/src/index.ts index afb6f2a..8c1116c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,20 @@ export function setNextLaunchOptions(options: FirefoxLaunchOptions): void { log('Next launch options updated'); } +/** + * Check if Firefox is currently running (without auto-starting) + */ +export function isFirefoxRunning(): boolean { + return firefox !== null; +} + +/** + * Get Firefox instance if running, null otherwise (no auto-start) + */ +export function getFirefoxIfRunning(): FirefoxDevTools | null { + return firefox; +} + export async function getFirefox(): Promise { // If we have an existing instance, verify it's still connected if (firefox) { @@ -130,10 +144,15 @@ export async function getFirefox(): Promise { } firefox = new FirefoxDevTools(options); - await firefox.connect(); - log('Firefox DevTools connection established'); - - return firefox; + try { + await firefox.connect(); + log('Firefox DevTools connection established'); + return firefox; + } catch (error) { + // Connection failed, clean up the failed instance + firefox = null; + throw error; + } } // Tool handler mapping diff --git a/src/tools/chrome-context.ts b/src/tools/chrome-context.ts index 14bd2e3..ff96efa 100644 --- a/src/tools/chrome-context.ts +++ b/src/tools/chrome-context.ts @@ -102,7 +102,7 @@ export async function handleSelectChromeContext(args: unknown): Promise | undefined; @@ -233,49 +241,106 @@ export async function handleRestartFirefox(input: unknown) { } } - // Merge with current options, preferring new values - const newOptions = { - ...currentOptions, - firefoxPath: firefoxPath ?? currentOptions.firefoxPath, - env: newEnv !== undefined ? newEnv : currentOptions.env, - headless: headless !== undefined ? headless : currentOptions.headless, - startUrl: startUrl ?? currentOptions.startUrl ?? 'about:home', - }; + // Check if Firefox is currently running and connected + const currentFirefox = getFirefoxIfRunning(); + const isConnected = currentFirefox ? await currentFirefox.isConnected() : false; + + if (currentFirefox && isConnected) { + // Firefox is running - restart with new config + const currentOptions = currentFirefox.getOptions(); + + // Merge with current options, preferring new values + const newOptions = { + ...currentOptions, + firefoxPath: firefoxPath ?? currentOptions.firefoxPath, + env: newEnv !== undefined ? newEnv : currentOptions.env, + headless: headless !== undefined ? headless : currentOptions.headless, + startUrl: startUrl ?? currentOptions.startUrl ?? 'about:home', + }; + + // Set options for next launch + setNextLaunchOptions(newOptions); + + // Close current instance (ignore errors - we're restarting anyway) + try { + await currentFirefox.close(); + } catch (error) { + // Ignore close errors - we'll reset anyway + } + resetFirefox(); - // Set options for next launch - setNextLaunchOptions(newOptions); + // Prepare change summary + const changes = []; + if (firefoxPath && firefoxPath !== currentOptions.firefoxPath) { + changes.push(`Binary: ${firefoxPath}`); + } + if (newEnv !== undefined && JSON.stringify(newEnv) !== JSON.stringify(currentOptions.env)) { + changes.push(`Environment variables updated:`); + for (const [key, value] of Object.entries(newEnv)) { + changes.push(` ${key}=${value}`); + } + } + if (headless !== undefined && headless !== currentOptions.headless) { + changes.push(`Headless: ${headless ? 'enabled' : 'disabled'}`); + } + if (startUrl && startUrl !== currentOptions.startUrl) { + changes.push(`Start URL: ${startUrl}`); + } - // Close current instance - await currentFirefox.close(); - resetFirefox(); + if (changes.length === 0) { + return successResponse( + '✅ Firefox closed. Will restart with same configuration on next tool call.' + ); + } - // Prepare change summary - const changes = []; - if (firefoxPath && firefoxPath !== currentOptions.firefoxPath) { - changes.push(`Binary: ${firefoxPath}`); - } - if (newEnv !== undefined && JSON.stringify(newEnv) !== JSON.stringify(currentOptions.env)) { - changes.push(`Environment variables updated:`); - for (const [key, value] of Object.entries(newEnv)) { - changes.push(` ${key}=${value}`); + return successResponse( + `✅ Firefox closed. Will restart with new configuration on next tool call:\n${changes.join('\n')}` + ); + } else { + // Firefox not running (or disconnected) - configure for first start + if (currentFirefox) { + // Had a stale disconnected reference, clean it up + resetFirefox(); + } + + // Use provided firefoxPath, or fall back to CLI args if available + const resolvedFirefoxPath = firefoxPath ?? args.firefoxPath ?? undefined; + + if (!resolvedFirefoxPath) { + return errorResponse( + new Error( + 'Firefox is not running and no firefoxPath provided. Please specify firefoxPath to start Firefox.' + ) + ); + } + + const newOptions = { + firefoxPath: resolvedFirefoxPath, + env: newEnv, + headless: headless ?? false, + startUrl: startUrl ?? 'about:home', + }; + + setNextLaunchOptions(newOptions); + + const config = [`Binary: ${resolvedFirefoxPath}`]; + if (newEnv) { + config.push('Environment variables:'); + for (const [key, value] of Object.entries(newEnv)) { + config.push(` ${key}=${value}`); + } + } + if (headless) { + config.push('Headless: enabled'); + } + if (startUrl) { + config.push(`Start URL: ${startUrl}`); } - } - if (headless !== undefined && headless !== currentOptions.headless) { - changes.push(`Headless: ${headless ? 'enabled' : 'disabled'}`); - } - if (startUrl && startUrl !== currentOptions.startUrl) { - changes.push(`Start URL: ${startUrl}`); - } - if (changes.length === 0) { return successResponse( - '✅ Firefox closed. Will restart with same configuration on next tool call.' + `✅ Firefox configured. Will start on next tool call:\n${config.join('\n')}` ); } - - return successResponse( - `✅ Firefox closed. Will restart with new configuration on next tool call:\n${changes.join('\n')}` - ); } catch (error) { return errorResponse(error as Error); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index c0add89..2728db0 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -11,10 +11,9 @@ export class FirefoxDisconnectedError extends Error { constructor(reason?: string) { const baseMessage = 'Firefox browser is not connected'; const instruction = - 'The Firefox browser window was closed by the user. ' + - 'To continue browser automation, ask the user to restart the firefox-devtools-mcp server ' + - '(they need to restart Claude Code or the MCP connection). ' + - 'This will launch a new Firefox instance.'; + 'The Firefox browser window was closed. ' + + 'Use the restart_firefox tool with firefoxPath parameter to start a new Firefox instance. ' + + 'Example: restart_firefox with firefoxPath="/usr/bin/firefox"'; const fullMessage = reason ? `${baseMessage}: ${reason}. ${instruction}` diff --git a/tests/utils/errors.test.ts b/tests/utils/errors.test.ts index a421c7a..60a3942 100644 --- a/tests/utils/errors.test.ts +++ b/tests/utils/errors.test.ts @@ -6,15 +6,15 @@ describe('FirefoxDisconnectedError', () => { const error = new FirefoxDisconnectedError(); expect(error.name).toBe('FirefoxDisconnectedError'); expect(error.message).toContain('Firefox browser is not connected'); - expect(error.message).toContain('ask the user to restart'); - expect(error.message).toContain('firefox-devtools-mcp server'); + expect(error.message).toContain('restart_firefox tool'); + expect(error.message).toContain('firefoxPath parameter'); }); it('should create error with custom reason', () => { const error = new FirefoxDisconnectedError('Browser was closed'); expect(error.message).toContain('Browser was closed'); expect(error.message).toContain('Firefox browser is not connected'); - expect(error.message).toContain('restart Claude Code or the MCP connection'); + expect(error.message).toContain('restart_firefox tool'); }); it('should be instanceof Error', () => { From cf70c83ee71f1466663048debb619e8350418a86 Mon Sep 17 00:00:00 2001 From: Paul Adenot Date: Wed, 21 Jan 2026 17:50:15 +0100 Subject: [PATCH 05/20] Add test scripts for navigation, MOZ_LOG, and chrome context --- test-chrome-context.js | 102 +++++++++++++++++++++++++++++++++++++ test-mozlog.js | 113 +++++++++++++++++++++++++++++++++++++++++ test-navigation.js | 57 +++++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100755 test-chrome-context.js create mode 100755 test-mozlog.js create mode 100755 test-navigation.js diff --git a/test-chrome-context.js b/test-chrome-context.js new file mode 100755 index 0000000..f497bb3 --- /dev/null +++ b/test-chrome-context.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { FirefoxDevTools } from './dist/index.js'; + +async function test() { + console.log('=== Test: Chrome Context Script Evaluation (headless) ===\n'); + + const firefox = new FirefoxDevTools({ + headless: true, + firefoxPath: process.env.HOME + '/firefox/firefox', + env: { + MOZ_REMOTE_ALLOW_SYSTEM_ACCESS: '1', + }, + }); + + await firefox.connect(); + console.log('✓ Firefox started with MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1'); + + // Test content script first + console.log('\n--- Testing content script (default context) ---'); + await firefox.navigate('https://example.com'); + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + const title = await firefox.evaluate('return document.title'); + console.log(`✓ Content context: document.title = "${title}"`); + } catch (err) { + console.log(`✗ Content script failed: ${err.message}`); + } + + // Now test chrome context via BiDi with moz:scope + console.log('\n--- Testing chrome context listing ---'); + + try { + // Use BiDi to list chrome contexts with moz:scope + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + + const contexts = result.contexts || []; + console.log(`✓ Listed ${contexts.length} chrome context(s) via BiDi`); + + if (contexts.length > 0) { + console.log(' Sample chrome contexts:'); + contexts.slice(0, 3).forEach(ctx => { + console.log(` ${ctx.context}: ${ctx.url || '(no url)'}`); + }); + + // Try to evaluate in chrome context + console.log('\n--- Testing chrome script execution ---'); + + const driver = firefox.getDriver(); + const firstContext = contexts[0]; + + // Switch to chrome browsing context + await driver.switchTo().window(firstContext.context); + console.log(`✓ Switched to chrome context: ${firstContext.context}`); + + // Set Marionette context to chrome + try { + await driver.setContext('chrome'); + console.log('✓ Set Marionette context to "chrome"'); + + // Now try to evaluate chrome-privileged script + const appName = await driver.executeScript('return Services.appinfo.name;'); + console.log(`✓ Chrome script: Services.appinfo.name = "${appName}"`); + + const version = await driver.executeScript('return Services.appinfo.version;'); + console.log(`✓ Chrome script: Services.appinfo.version = "${version}"`); + + const buildID = await driver.executeScript('return Services.appinfo.appBuildID;'); + console.log(`✓ Chrome script: Services.appinfo.appBuildID = "${buildID}"`); + + console.log('\n✅ Chrome context evaluation WORKS!'); + + } catch (err) { + console.log(`✗ Failed to set chrome context: ${err.message}`); + console.log(' Your Firefox build may not support chrome context'); + } + + } else { + console.log(' No chrome contexts found (requires dev/nightly build)'); + } + + } catch (err) { + if (err.message && err.message.includes('UnsupportedOperationError')) { + console.log('✗ Chrome context not supported by this Firefox build'); + console.log(' Requires Firefox Nightly or custom build'); + } else { + console.log(`✗ Chrome context test failed: ${err.message}`); + } + } + + await firefox.close(); + console.log('\n✓ Test completed'); +} + +test().catch(err => { + console.error('\nTest failed:', err); + console.error(err.stack); + process.exit(1); +}); diff --git a/test-mozlog.js b/test-mozlog.js new file mode 100755 index 0000000..b291c40 --- /dev/null +++ b/test-mozlog.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { FirefoxDevTools } from './dist/index.js'; +import { readFileSync, existsSync } from 'fs'; + +async function test() { + console.log('=== Test: MOZ_LOG and Script Injection (headless) ===\n'); + + const logFile = '/tmp/firefox-mozlog-test.log'; + + const firefox = new FirefoxDevTools({ + headless: true, + firefoxPath: process.env.HOME + '/firefox/firefox', + env: { + MOZ_LOG: 'timestamp,sync,nsHttp:5', + }, + logFile, + }); + + await firefox.connect(); + console.log('✓ Firefox started in headless mode with MOZ_LOG'); + console.log(` Log file: ${logFile}`); + + // Test content script evaluation + console.log('\n--- Testing content script evaluation ---'); + await firefox.navigate('https://example.com'); + console.log('✓ Navigated to example.com'); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + const title = await firefox.evaluate('return document.title'); + console.log(`✓ Content script: document.title = "${title}"`); + } catch (err) { + console.log(`✗ Content script evaluation failed: ${err.message}`); + } + + try { + const url = await firefox.evaluate('return window.location.href'); + console.log(`✓ Content script: window.location.href = "${url}"`); + } catch (err) { + console.log(`✗ Failed to access window.location: ${err.message}`); + } + + try { + const headings = await firefox.evaluate('return document.querySelectorAll("h1").length'); + console.log(`✓ Content script: found ${headings} h1 elements`); + } catch (err) { + console.log(`✗ Failed to query DOM: ${err.message}`); + } + + // Navigate to another page + await firefox.navigate('https://mozilla.org'); + console.log('✓ Navigated to mozilla.org'); + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + const title2 = await firefox.evaluate('return document.title'); + console.log(`✓ Content script: document.title = "${title2}"`); + } catch (err) { + console.log(`✗ Content script evaluation failed: ${err.message}`); + } + + // Check log file + console.log('\n--- Checking MOZ_LOG output ---'); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + await firefox.close(); + console.log('✓ Firefox closed'); + + // Give a moment for log file to be flushed + await new Promise(resolve => setTimeout(resolve, 1000)); + + if (existsSync(logFile)) { + try { + const logContent = readFileSync(logFile, 'utf8'); + const lines = logContent.split('\n').filter(l => l.trim()); + console.log(`✓ Log file exists with ${lines.length} lines`); + + // Check for HTTP logging + const httpLines = lines.filter(l => l.includes('nsHttp')); + console.log(` Found ${httpLines.length} nsHttp log lines`); + + if (httpLines.length > 0) { + console.log(' Sample HTTP log lines:'); + httpLines.slice(0, 3).forEach(line => { + console.log(` ${line.substring(0, 100)}`); + }); + } + + // Check for timestamps + const timestampLines = lines.filter(l => /^\d{4}-\d{2}-\d{2}/.test(l)); + console.log(` Found ${timestampLines.length} timestamped lines`); + + } catch (err) { + console.log(`✗ Could not read log file: ${err.message}`); + } + } else { + console.log(`✗ Log file does not exist: ${logFile}`); + } + + console.log('\n✓ All feature tests completed!'); + console.log('\nNote: Chrome context evaluation requires:'); + console.log(' - MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var'); + console.log(' - Using restart_firefox tool or npm run inspector'); +} + +test().catch(err => { + console.error('\nTest failed:', err); + console.error(err.stack); + process.exit(1); +}); diff --git a/test-navigation.js b/test-navigation.js new file mode 100755 index 0000000..28b6c5a --- /dev/null +++ b/test-navigation.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import { FirefoxDevTools } from './dist/index.js'; + +async function test() { + console.log('=== Test: Start Firefox and navigate ===\n'); + + const firefox = new FirefoxDevTools({ + headless: false, + firefoxPath: process.env.HOME + '/firefox/firefox', + }); + + await firefox.connect(); + console.log('✓ Firefox started'); + + await firefox.navigate('https://example.com'); + console.log('✓ Navigated to example.com'); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + await firefox.navigate('https://mozilla.org'); + console.log('✓ Navigated to mozilla.org'); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + await firefox.refreshTabs(); + const tabs = firefox.getTabs(); + console.log(`✓ Listed tabs: ${tabs.length} tab(s)`); + if (tabs.length > 0) { + console.log(` Current URL: ${tabs[0].url}`); + console.log(` Current title: ${tabs[0].title || 'N/A'}`); + } + + await new Promise(resolve => setTimeout(resolve, 2000)); + + await firefox.navigate('https://www.w3.org'); + console.log('✓ Navigated to w3.org'); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + await firefox.refreshTabs(); + const tabsAfter = firefox.getTabs(); + console.log(`✓ Listed tabs again: ${tabsAfter.length} tab(s)`); + if (tabsAfter.length > 0) { + console.log(` Current URL: ${tabsAfter[0].url}`); + } + + await firefox.close(); + console.log('\n✓ Basic navigation tests passed!'); + console.log('\nNote: To test restart_firefox with logs, use the MCP inspector:'); + console.log(' npm run inspector'); +} + +test().catch(err => { + console.error('\nTest failed:', err); + process.exit(1); +}); From 976c25fd39721408db67cb031a86efd02f2fbff5 Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Thu, 29 Jan 2026 15:05:37 -0800 Subject: [PATCH 06/20] fix(firefox): Use native --profile arg for reliable profile loading Selenium's setProfile() copies the profile to a temp directory which can silently fail or not preserve all profile data. Using Firefox's native --profile command-line argument directly ensures the configured profile is actually used. Adds test to verify profile path is passed via --profile argument. --- src/firefox/core.ts | 5 ++- tests/firefox/core.test.ts | 65 +++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/firefox/core.ts b/src/firefox/core.ts index af328cf..bbe6220 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -534,7 +534,10 @@ export class FirefoxCore { firefoxOptions.addArguments(...this.options.args); } if (this.options.profilePath) { - firefoxOptions.setProfile(this.options.profilePath); + // Use Firefox's native --profile argument for reliable profile loading + // (Selenium's setProfile() copies to temp dir which can be unreliable) + firefoxOptions.addArguments('--profile', this.options.profilePath); + log(`📁 Using Firefox profile: ${this.options.profilePath}`); } if (this.options.acceptInsecureCerts) { firefoxOptions.setAcceptInsecureCerts(true); diff --git a/tests/firefox/core.test.ts b/tests/firefox/core.test.ts index 523c495..28711cd 100644 --- a/tests/firefox/core.test.ts +++ b/tests/firefox/core.test.ts @@ -2,7 +2,7 @@ * Unit tests for FirefoxCore module */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { FirefoxCore } from '@/firefox/core.js'; import type { FirefoxLaunchOptions } from '@/firefox/types.js'; @@ -74,3 +74,66 @@ describe('FirefoxCore', () => { }); }); }); + +// Tests for connect() behavior with mocked Selenium +describe('FirefoxCore connect() profile handling', () => { + // Mock selenium-webdriver/firefox.js at module level + const mockAddArguments = vi.fn(); + const mockSetProfile = vi.fn(); + const mockEnableBidi = vi.fn(); + const mockSetBinary = vi.fn(); + const mockWindowSize = vi.fn(); + const mockSetAcceptInsecureCerts = vi.fn(); + const mockSetStdio = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: mockEnableBidi, + addArguments: mockAddArguments, + setProfile: mockSetProfile, + setBinary: mockSetBinary, + windowSize: mockWindowSize, + setAcceptInsecureCerts: mockSetAcceptInsecureCerts, + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: mockSetStdio, + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: vi.fn().mockResolvedValue('mock-context-id'), + get: vi.fn().mockResolvedValue(undefined), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + }); + + it('should pass profile path via --profile argument instead of setProfile', async () => { + const { FirefoxCore } = await import('@/firefox/core.js'); + + const profilePath = '/path/to/test/profile'; + const core = new FirefoxCore({ + headless: true, + profilePath, + }); + + await core.connect(); + + // Assert: setProfile should NOT be called (it copies to temp dir) + expect(mockSetProfile).not.toHaveBeenCalled(); + + expect(mockAddArguments).toHaveBeenCalledWith('--profile', profilePath); + }); +}); From 88bb114f7f95dde8e886e5bb48a5d62bee2c7350 Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Thu, 29 Jan 2026 18:37:50 -0800 Subject: [PATCH 07/20] fix(firefox): Preserve profile path across restart_firefox calls Previously, restart_firefox would lose the configured profile path when restarting Firefox. Now the tool preserves the current profile path by default and adds a profilePath parameter to allow changing it at runtime. Documentation updated to reflect: - New profilePath parameter in restart_firefox - Firefox management tools in tool overview - Corrected profile management behavior (uses native --profile arg) --- README.md | 1 + docs/firefox-client.md | 19 ++- src/tools/firefox-management.ts | 16 ++- tests/tools/firefox-management.test.ts | 155 +++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 tests/tools/firefox-management.test.ts diff --git a/README.md b/README.md index 34f7b0b..a4474e5 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ BiDi-dependent features (console events, network events) are not available in co - Screenshot: page/by uid (with optional `saveTo` for CLI environments) - Script: evaluate_script (content), evaluate_chrome_script (privileged) - Chrome Context: list/select chrome contexts (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) +- Firefox Management: get_firefox_info, get_firefox_output, restart_firefox - Utilities: accept/dismiss dialog, history back/forward, set viewport ### Screenshot optimization for Claude Code diff --git a/docs/firefox-client.md b/docs/firefox-client.md index a2c47c0..00d99ca 100644 --- a/docs/firefox-client.md +++ b/docs/firefox-client.md @@ -141,6 +141,7 @@ Selenium automatically manages Firefox through geckodriver: --firefox-path # Firefox executable path --headless # Run Firefox headless --viewport # Set viewport size (e.g., 1280x720) +--profile-path # Firefox profile path --start-url # Initial URL to navigate to ``` @@ -151,9 +152,9 @@ START_URL=https://example.com ``` **Profile Management:** -- Selenium creates temporary profiles automatically -- Custom profile support via `firefoxOptions.setProfile()` -- Automatic cleanup on shutdown +- Use `--profile-path` to specify a Firefox profile directory +- Profile is loaded in-place via Firefox's native `--profile` argument (not copied to temp) +- Runtime profile changes supported via `restart_firefox` tool's `profilePath` parameter ## Available Tools @@ -188,6 +189,18 @@ The server provides comprehensive browser automation tools: | `stop_network_monitoring` | Disable network capture | ✅ Implemented | | `performance_get_metrics` | Get timing metrics | ✅ Via `performance` API | +### Firefox Management + +| Tool | Description | Parameters | +|------|-------------|------------| +| `get_firefox_info` | Get current Firefox configuration | (none) | +| `get_firefox_output` | Get Firefox stdout/stderr/MOZ_LOG output | `lines`, `grep`, `since` | +| `restart_firefox` | Restart or configure Firefox | `firefoxPath`, `profilePath`, `env`, `headless`, `startUrl` | + +**Note:** `restart_firefox` works in two modes: +- If Firefox is running: closes and restarts with new configuration +- If Firefox is not running: configures options for next tool call that triggers launch + ✅ = Fully implemented ## Migration from RDP diff --git a/src/tools/firefox-management.ts b/src/tools/firefox-management.ts index b79da1d..d7a2bc4 100644 --- a/src/tools/firefox-management.ts +++ b/src/tools/firefox-management.ts @@ -193,6 +193,10 @@ export const restartFirefoxTool = { type: 'string', description: 'New Firefox binary path (optional, keeps current if not specified)', }, + profilePath: { + type: 'string', + description: 'Firefox profile path (optional, keeps current if not specified)', + }, env: { type: 'array', items: { @@ -216,8 +220,9 @@ export const restartFirefoxTool = { export async function handleRestartFirefox(input: unknown) { try { - const { firefoxPath, env, headless, startUrl } = input as { + const { firefoxPath, profilePath, env, headless, startUrl } = input as { firefoxPath?: string; + profilePath?: string; env?: string[]; headless?: boolean; startUrl?: string; @@ -253,6 +258,7 @@ export async function handleRestartFirefox(input: unknown) { const newOptions = { ...currentOptions, firefoxPath: firefoxPath ?? currentOptions.firefoxPath, + profilePath: profilePath ?? currentOptions.profilePath, env: newEnv !== undefined ? newEnv : currentOptions.env, headless: headless !== undefined ? headless : currentOptions.headless, startUrl: startUrl ?? currentOptions.startUrl ?? 'about:home', @@ -274,6 +280,9 @@ export async function handleRestartFirefox(input: unknown) { if (firefoxPath && firefoxPath !== currentOptions.firefoxPath) { changes.push(`Binary: ${firefoxPath}`); } + if (profilePath && profilePath !== currentOptions.profilePath) { + changes.push(`Profile: ${profilePath}`); + } if (newEnv !== undefined && JSON.stringify(newEnv) !== JSON.stringify(currentOptions.env)) { changes.push(`Environment variables updated:`); for (const [key, value] of Object.entries(newEnv)) { @@ -316,6 +325,7 @@ export async function handleRestartFirefox(input: unknown) { const newOptions = { firefoxPath: resolvedFirefoxPath, + profilePath: profilePath ?? args.profilePath ?? undefined, env: newEnv, headless: headless ?? false, startUrl: startUrl ?? 'about:home', @@ -324,6 +334,10 @@ export async function handleRestartFirefox(input: unknown) { setNextLaunchOptions(newOptions); const config = [`Binary: ${resolvedFirefoxPath}`]; + const resolvedProfilePath = profilePath ?? args.profilePath; + if (resolvedProfilePath) { + config.push(`Profile: ${resolvedProfilePath}`); + } if (newEnv) { config.push('Environment variables:'); for (const [key, value] of Object.entries(newEnv)) { diff --git a/tests/tools/firefox-management.test.ts b/tests/tools/firefox-management.test.ts new file mode 100644 index 0000000..2f49615 --- /dev/null +++ b/tests/tools/firefox-management.test.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for Firefox management tools (restart_firefox, get_firefox_info, get_firefox_output) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { restartFirefoxTool } from '../../src/tools/firefox-management.js'; + +// Create mock functions that will be used in the hoisted mock +const mockSetNextLaunchOptions = vi.hoisted(() => vi.fn()); +const mockResetFirefox = vi.hoisted(() => vi.fn()); +const mockGetFirefoxIfRunning = vi.hoisted(() => vi.fn()); +const mockArgs = vi.hoisted(() => ({ + firefoxPath: undefined as string | undefined, + profilePath: undefined as string | undefined, +})); + +vi.mock('../../src/index.js', () => ({ + args: mockArgs, + getFirefoxIfRunning: () => mockGetFirefoxIfRunning(), + setNextLaunchOptions: (opts: unknown) => mockSetNextLaunchOptions(opts), + resetFirefox: () => mockResetFirefox(), + getFirefox: vi.fn(), +})); + +describe('Firefox Management Tools', () => { + describe('restartFirefoxTool schema', () => { + it('should have profilePath in input schema properties', () => { + const { properties } = restartFirefoxTool.inputSchema as { + properties: Record; + }; + expect(properties.profilePath).toBeDefined(); + expect(properties.profilePath.type).toBe('string'); + expect(properties.profilePath.description).toContain('profile'); + }); + }); + + describe('handleRestartFirefox', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockArgs.firefoxPath = undefined; + mockArgs.profilePath = undefined; + }); + + describe('when Firefox is NOT running', () => { + beforeEach(() => { + mockGetFirefoxIfRunning.mockReturnValue(null); + mockArgs.firefoxPath = '/path/to/firefox'; + }); + + it('should use provided profilePath in launch options', async () => { + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({ profilePath: '/custom/profile' }); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + profilePath: '/custom/profile', + }) + ); + }); + + it('should fall back to args.profilePath when profilePath not specified', async () => { + mockArgs.profilePath = '/cli/profile'; + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({}); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + profilePath: '/cli/profile', + }) + ); + }); + + it('should use provided profilePath over args.profilePath', async () => { + mockArgs.profilePath = '/cli/profile'; + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({ profilePath: '/override/profile' }); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + profilePath: '/override/profile', + }) + ); + }); + + it('should set profilePath to undefined when neither provided nor in CLI args', async () => { + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({}); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + profilePath: undefined, + }) + ); + }); + }); + + describe('when Firefox IS running', () => { + const mockFirefoxInstance = { + getOptions: vi.fn(), + isConnected: vi.fn(), + close: vi.fn(), + }; + + beforeEach(() => { + mockGetFirefoxIfRunning.mockReturnValue(mockFirefoxInstance); + mockFirefoxInstance.isConnected.mockResolvedValue(true); + mockFirefoxInstance.close.mockResolvedValue(undefined); + mockFirefoxInstance.getOptions.mockReturnValue({ + firefoxPath: '/current/firefox', + profilePath: '/current/profile', + headless: false, + env: {}, + }); + }); + + it('should preserve currentOptions.profilePath when not specified', async () => { + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({}); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + profilePath: '/current/profile', + }) + ); + }); + + it('should use provided profilePath when specified', async () => { + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({ profilePath: '/new/profile' }); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + profilePath: '/new/profile', + }) + ); + }); + + it('should include profilePath in change summary when changed', async () => { + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + const result = await handleRestartFirefox({ profilePath: '/new/profile' }); + + const text = result.content[0].text; + expect(text).toContain('Profile'); + expect(text).toContain('/new/profile'); + }); + }); + }); +}); From 8d092032aaa43fe2e6892a775c312ff35c1f8ca7 Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Thu, 29 Jan 2026 19:58:51 -0800 Subject: [PATCH 08/20] feat(firefox): Add Firefox preferences configuration Allow overriding Firefox's RecommendedPreferences via CLI and runtime tools. When Firefox runs in WebDriver BiDi mode, it applies preferences that change default behavior for test reliability. This feature enables Firefox developers and testers to override these when needed. Adds: - CLI: --pref name=value (repeatable, requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1) - Tools: set_firefox_prefs, get_firefox_prefs - restart_firefox: new prefs parameter for setting on restart - get_firefox_info: shows configured preferences Preferences are preserved across restarts and re-applied automatically. --- README.md | 5 +- docs/firefox-client.md | 39 +- src/cli.ts | 51 +++ src/firefox/core.ts | 90 +++++ src/firefox/index.ts | 8 + src/firefox/pref-utils.ts | 22 ++ src/firefox/types.ts | 2 + src/index.ts | 15 +- src/tools/firefox-management.ts | 24 +- src/tools/firefox-prefs.ts | 261 +++++++++++++ src/tools/index.ts | 8 + tests/cli/prefs-parsing.test.ts | 109 ++++++ tests/firefox/core-prefs.test.ts | 490 +++++++++++++++++++++++++ tests/firefox/pref-utils.test.ts | 45 +++ tests/firefox/types.test.ts | 33 ++ tests/tools/firefox-management.test.ts | 111 +++++- tests/tools/firefox-prefs.test.ts | 309 ++++++++++++++++ 17 files changed, 1616 insertions(+), 6 deletions(-) create mode 100644 src/firefox/pref-utils.ts create mode 100644 src/tools/firefox-prefs.ts create mode 100644 tests/cli/prefs-parsing.test.ts create mode 100644 tests/firefox/core-prefs.test.ts create mode 100644 tests/firefox/pref-utils.test.ts create mode 100644 tests/firefox/types.test.ts create mode 100644 tests/tools/firefox-prefs.test.ts diff --git a/README.md b/README.md index a4474e5..24019bf 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ You can pass flags or environment variables (names on the right): - `--accept-insecure-certs` — ignore TLS errors (`ACCEPT_INSECURE_CERTS=true`) - `--connect-existing` — attach to an already-running Firefox instead of launching a new one (`CONNECT_EXISTING=true`) - `--marionette-port` — Marionette port for connect-existing mode, default 2828 (`MARIONETTE_PORT`) +- `--pref name=value` — set Firefox preference at startup (repeatable, requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) ### Connect to existing Firefox @@ -118,6 +119,8 @@ BiDi-dependent features (console events, network events) are not available in co > Only enable Marionette when you need MCP automation, then restart Firefox > normally afterward. +> **Note on `--pref`:** When Firefox runs in WebDriver BiDi mode, it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that modify browser behavior for testing. The `--pref` option allows overriding these defaults when needed (e.g., for Firefox development, debugging, or testing scenarios that require production-like behavior). + ## Tool overview - Pages: list/new/navigate/select/close @@ -128,7 +131,7 @@ BiDi-dependent features (console events, network events) are not available in co - Screenshot: page/by uid (with optional `saveTo` for CLI environments) - Script: evaluate_script (content), evaluate_chrome_script (privileged) - Chrome Context: list/select chrome contexts (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) -- Firefox Management: get_firefox_info, get_firefox_output, restart_firefox +- Firefox Management: get_firefox_info, get_firefox_output, restart_firefox, set_firefox_prefs, get_firefox_prefs - Utilities: accept/dismiss dialog, history back/forward, set viewport ### Screenshot optimization for Claude Code diff --git a/docs/firefox-client.md b/docs/firefox-client.md index 00d99ca..ef569b3 100644 --- a/docs/firefox-client.md +++ b/docs/firefox-client.md @@ -143,6 +143,7 @@ Selenium automatically manages Firefox through geckodriver: --viewport # Set viewport size (e.g., 1280x720) --profile-path # Firefox profile path --start-url # Initial URL to navigate to +--pref # Set Firefox preference (repeatable, requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1) ``` **Environment Variables:** @@ -156,6 +157,38 @@ START_URL=https://example.com - Profile is loaded in-place via Firefox's native `--profile` argument (not copied to temp) - Runtime profile changes supported via `restart_firefox` tool's `profilePath` parameter +### Firefox Preferences + +When Firefox runs in WebDriver BiDi mode (automated testing), it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that change default behavior for test reliability. The `--pref` option and preference tools allow overriding these when needed. + +**Use cases:** +- Firefox development and debugging +- Testing scenarios requiring production-like behavior +- Enabling specific features disabled by RecommendedPreferences + +**Setting preferences:** + +At startup via CLI (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`): +```bash +npx firefox-devtools-mcp --pref "browser.cache.disk.enable=true" --pref "dom.webnotifications.enabled=true" +``` + +At runtime via tools: +```javascript +// Set preferences +await set_firefox_prefs({ prefs: { "browser.cache.disk.enable": true } }); + +// Get preference values +await get_firefox_prefs({ names: ["browser.cache.disk.enable", "dom.webnotifications.enabled"] }); + +// Via restart_firefox +await restart_firefox({ prefs: { "browser.cache.disk.enable": true } }); +``` + +**Note:** Preference tools require `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` environment variable. + +**Preference persistence:** Preferences set via CLI or `restart_firefox` are preserved across restarts. When `restart_firefox` is called without a `prefs` parameter, existing preferences are re-applied automatically. + ## Available Tools The server provides comprehensive browser automation tools: @@ -195,7 +228,11 @@ The server provides comprehensive browser automation tools: |------|-------------|------------| | `get_firefox_info` | Get current Firefox configuration | (none) | | `get_firefox_output` | Get Firefox stdout/stderr/MOZ_LOG output | `lines`, `grep`, `since` | -| `restart_firefox` | Restart or configure Firefox | `firefoxPath`, `profilePath`, `env`, `headless`, `startUrl` | +| `restart_firefox` | Restart or configure Firefox | `firefoxPath`, `profilePath`, `env`, `headless`, `startUrl`, `prefs` | +| `set_firefox_prefs` | Set Firefox preferences at runtime | `prefs` (object) | +| `get_firefox_prefs` | Get Firefox preference values | `names` (array) | + +**Note:** `set_firefox_prefs` and `get_firefox_prefs` require `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` environment variable. **Note:** `restart_firefox` works in two modes: - If Firefox is running: closes and restarts with new configuration diff --git a/src/cli.ts b/src/cli.ts index d9ede30..f6c9652 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,50 @@ import type { Options as YargsOptions } from 'yargs'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +/** + * Parsed preference value (boolean, integer, or string) + */ +export type PrefValue = string | number | boolean; + +/** + * Parse preference strings into typed values + * Format: "name=value" where value is auto-typed as boolean/integer/string + */ +export function parsePrefs(prefs: string[] | undefined): Record { + const result: Record = {}; + + if (!prefs || prefs.length === 0) { + return result; + } + + for (const pref of prefs) { + const eqIndex = pref.indexOf('='); + if (eqIndex === -1) { + // Skip malformed entries (no equals sign) + continue; + } + + const name = pref.slice(0, eqIndex); + const rawValue = pref.slice(eqIndex + 1); + + // Type inference + let value: PrefValue; + if (rawValue === 'true') { + value = true; + } else if (rawValue === 'false') { + value = false; + } else if (/^-?\d+$/.test(rawValue)) { + value = parseInt(rawValue, 10); + } else { + value = rawValue; + } + + result[name] = value; + } + + return result; +} + export const cliOptions = { firefoxPath: { type: 'string', @@ -76,6 +120,13 @@ export const cliOptions = { description: 'Path to file where Firefox output (stdout/stderr) will be written. If not specified, output is written to ~/.firefox-devtools-mcp/output/', }, + pref: { + type: 'array', + string: true, + description: + 'Set Firefox preference at startup (format: name=value). Can be specified multiple times. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1.', + alias: 'p', + }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { diff --git a/src/firefox/core.ts b/src/firefox/core.ts index bbe6220..bfc6fbe 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -10,6 +10,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import type { FirefoxLaunchOptions } from './types.js'; import { log, logDebug } from '../utils/logger.js'; +import { generatePrefScript } from './pref-utils.js'; // --------------------------------------------------------------------------- // Shared driver interface — the minimal surface used by all consumers @@ -583,6 +584,11 @@ export class FirefoxCore { logDebug(`Navigated to: ${this.options.startUrl}`); } + // Apply preferences if configured + if (this.options.prefs && Object.keys(this.options.prefs).length > 0) { + await this.applyPreferences(); + } + log('✅ Firefox DevTools ready'); } @@ -654,6 +660,90 @@ export class FirefoxCore { return this.options; } + /** + * Apply Firefox preferences via Services.prefs API + * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable + */ + async applyPreferences(): Promise { + const prefs = this.options.prefs; + + // Return early if no prefs to set + if (!prefs || Object.keys(prefs).length === 0) { + return; + } + + // Check for MOZ_REMOTE_ALLOW_SYSTEM_ACCESS + if (!process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS) { + throw new Error( + 'MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable is required to set Firefox preferences at startup. ' + + 'Add --env MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 to your command line.' + ); + } + + if (!this.driver) { + throw new Error('Driver not connected'); + } + + // Get chrome contexts + const result = await this.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + + const contexts = result.contexts || []; + if (contexts.length === 0) { + throw new Error( + 'No chrome contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + ); + } + + const chromeContextId = contexts[0].context; + const originalContextId = this.currentContextId; + + const successes: string[] = []; + const failures: string[] = []; + + try { + // Switch to chrome context + await this.driver.switchTo().window(chromeContextId); + await (this.driver as any).setContext('chrome'); + + // Set each preference + for (const [name, value] of Object.entries(prefs)) { + try { + const script = generatePrefScript(name, value); + await this.driver.executeScript(script); + successes.push(`${name} = ${JSON.stringify(value)}`); + } catch (error) { + failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Log results + if (successes.length > 0) { + log(`✅ Applied ${successes.length} Firefox preference(s)`); + for (const msg of successes) { + logDebug(` ${msg}`); + } + } + if (failures.length > 0) { + log(`⚠ Failed to set ${failures.length} preference(s)`); + for (const msg of failures) { + logDebug(` ${msg}`); + } + } + } finally { + // Restore content context + try { + await (this.driver as any).setContext('content'); + if (originalContextId) { + await this.driver.switchTo().window(originalContextId); + } + } catch { + // Ignore errors restoring context + } + } + } + /** * Send raw BiDi command and get response */ diff --git a/src/firefox/index.ts b/src/firefox/index.ts index 25d749b..d5460be 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -390,6 +390,14 @@ export class FirefoxClient { return this.core.getDriver(); } + /** + * Get current browsing context ID (for advanced operations) + * @internal + */ + getCurrentContextId(): string | null { + return this.core.getCurrentContextId(); + } + /** * Check if Firefox is still connected and responsive * Returns false if Firefox was closed or connection is broken diff --git a/src/firefox/pref-utils.ts b/src/firefox/pref-utils.ts new file mode 100644 index 0000000..6d9f0a8 --- /dev/null +++ b/src/firefox/pref-utils.ts @@ -0,0 +1,22 @@ +/** + * Firefox preference utilities + * Helper functions for working with Services.prefs API + */ + +/** + * Generate a Services.prefs.set*Pref script for a given preference name and value + * Uses setBoolPref for booleans, setIntPref for numbers, setStringPref for strings + */ +export function generatePrefScript(name: string, value: string | number | boolean): string { + // Escape quotes in the name + const escapedName = JSON.stringify(name); + + if (typeof value === 'boolean') { + return `Services.prefs.setBoolPref(${escapedName}, ${value})`; + } else if (typeof value === 'number') { + return `Services.prefs.setIntPref(${escapedName}, ${value})`; + } else { + // String value - JSON.stringify handles escaping + return `Services.prefs.setStringPref(${escapedName}, ${JSON.stringify(value)})`; + } +} diff --git a/src/firefox/types.ts b/src/firefox/types.ts index 2df38de..5a7f8b7 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -62,6 +62,8 @@ export interface FirefoxLaunchOptions { marionettePort?: number | undefined; env?: Record | undefined; logFile?: string | undefined; + /** Firefox preferences to set at startup via Services.prefs API */ + prefs?: Record | undefined; } /** diff --git a/src/index.ts b/src/index.ts index 8c1116c..e8d158b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ import { import { SERVER_NAME, SERVER_VERSION } from './config/constants.js'; import { log, logError, logDebug } from './utils/logger.js'; -import { parseArguments } from './cli.js'; +import { parseArguments, parsePrefs } from './cli.js'; import { FirefoxDevTools } from './firefox/index.js'; import type { FirefoxLaunchOptions } from './firefox/types.js'; import * as tools from './tools/index.js'; @@ -128,6 +128,10 @@ export async function getFirefox(): Promise { } } + // Parse preferences from CLI args + const prefValues = parsePrefs(args.pref); + const prefs = Object.keys(prefValues).length > 0 ? prefValues : undefined; + options = { firefoxPath: args.firefoxPath ?? undefined, headless: args.headless, @@ -140,6 +144,7 @@ export async function getFirefox(): Promise { marionettePort: args.marionettePort, env: envVars, logFile: args.outputFile ?? undefined, + prefs, }; } @@ -207,6 +212,10 @@ const toolHandlers = new Map Promise 0) { + info.push(''); + info.push('Preferences:'); + for (const [key, value] of Object.entries(options.prefs)) { + info.push(` ${key} = ${JSON.stringify(value)}`); + } + } + if (logFilePath) { info.push(''); info.push(`Output File: ${logFilePath}`); @@ -214,18 +222,27 @@ export const restartFirefoxTool = { description: 'URL to navigate to after restart (optional, uses about:home if not specified)', }, + prefs: { + type: 'object', + description: + 'Firefox preferences to set at startup. Values are auto-typed: true/false become booleans, integers become numbers, everything else is a string. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1.', + additionalProperties: { + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }, + }, }, }, }; export async function handleRestartFirefox(input: unknown) { try { - const { firefoxPath, profilePath, env, headless, startUrl } = input as { + const { firefoxPath, profilePath, env, headless, startUrl, prefs } = input as { firefoxPath?: string; profilePath?: string; env?: string[]; headless?: boolean; startUrl?: string; + prefs?: Record; }; // This tool is designed to be robust and never get stuck: @@ -254,6 +271,10 @@ export async function handleRestartFirefox(input: unknown) { // Firefox is running - restart with new config const currentOptions = currentFirefox.getOptions(); + // Merge prefs: combine existing with new, new takes precedence + const mergedPrefs = + prefs !== undefined ? { ...(currentOptions.prefs || {}), ...prefs } : currentOptions.prefs; + // Merge with current options, preferring new values const newOptions = { ...currentOptions, @@ -262,6 +283,7 @@ export async function handleRestartFirefox(input: unknown) { env: newEnv !== undefined ? newEnv : currentOptions.env, headless: headless !== undefined ? headless : currentOptions.headless, startUrl: startUrl ?? currentOptions.startUrl ?? 'about:home', + prefs: mergedPrefs, }; // Set options for next launch diff --git a/src/tools/firefox-prefs.ts b/src/tools/firefox-prefs.ts new file mode 100644 index 0000000..2fd422c --- /dev/null +++ b/src/tools/firefox-prefs.ts @@ -0,0 +1,261 @@ +/** + * Firefox Preferences Tools + * Tools for getting and setting Firefox preferences via Services.prefs API + * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 + */ + +import { successResponse, errorResponse } from '../utils/response-helpers.js'; +import { generatePrefScript } from '../firefox/pref-utils.js'; +import type { McpToolResponse } from '../types/common.js'; + +// ============================================================================ +// Tool: set_firefox_prefs +// ============================================================================ + +export const setFirefoxPrefsTool = { + name: 'set_firefox_prefs', + description: + 'Set Firefox preferences at runtime via Services.prefs API. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.', + inputSchema: { + type: 'object', + properties: { + prefs: { + type: 'object', + description: + 'Object mapping preference names to values. Values are auto-typed: true/false become booleans, integers become numbers, everything else is a string.', + additionalProperties: { + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }, + }, + }, + required: ['prefs'], + }, +}; + +export async function handleSetFirefoxPrefs(args: unknown): Promise { + try { + const { prefs } = args as { prefs: Record }; + + if (!prefs || typeof prefs !== 'object') { + throw new Error('prefs parameter is required and must be an object'); + } + + const prefEntries = Object.entries(prefs); + if (prefEntries.length === 0) { + return successResponse('No preferences to set'); + } + + // Check for MOZ_REMOTE_ALLOW_SYSTEM_ACCESS + if (!process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS) { + throw new Error( + 'MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable is required to set Firefox preferences. ' + + 'Use restart_firefox with env parameter to enable.' + ); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + // Get chrome contexts + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + + const contexts = result.contexts || []; + if (contexts.length === 0) { + throw new Error( + 'No chrome contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + ); + } + + const driver = firefox.getDriver(); + const chromeContextId = contexts[0].context; + + // Remember current context + const originalContextId = firefox.getCurrentContextId(); + + try { + // Switch to chrome context + await driver.switchTo().window(chromeContextId); + await driver.setContext('chrome'); + + const results: string[] = []; + const errors: string[] = []; + + // Set each preference + for (const [name, value] of prefEntries) { + try { + const script = generatePrefScript(name, value); + await driver.executeScript(script); + results.push(` ${name} = ${JSON.stringify(value)}`); + } catch (error) { + errors.push(` ${name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const output: string[] = []; + if (results.length > 0) { + output.push(`✅ Set ${results.length} preference(s):`); + output.push(...results); + } + if (errors.length > 0) { + output.push(`\n⚠ Failed to set ${errors.length} preference(s):`); + output.push(...errors); + } + + return successResponse(output.join('\n')); + } finally { + // Restore content context + try { + await driver.setContext('content'); + if (originalContextId) { + await driver.switchTo().window(originalContextId); + } + } catch { + // Ignore errors restoring context + } + } + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + return errorResponse( + new Error( + 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' + ) + ); + } + return errorResponse(error as Error); + } +} + +// ============================================================================ +// Tool: get_firefox_prefs +// ============================================================================ + +export const getFirefoxPrefsTool = { + name: 'get_firefox_prefs', + description: + 'Get Firefox preference values via Services.prefs API. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.', + inputSchema: { + type: 'object', + properties: { + names: { + type: 'array', + items: { type: 'string' }, + description: 'Array of preference names to read', + }, + }, + required: ['names'], + }, +}; + +export async function handleGetFirefoxPrefs(args: unknown): Promise { + try { + const { names } = args as { names: string[] }; + + if (!names || !Array.isArray(names) || names.length === 0) { + throw new Error('names parameter is required and must be a non-empty array'); + } + + // Check for MOZ_REMOTE_ALLOW_SYSTEM_ACCESS + if (!process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS) { + throw new Error( + 'MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable is required to read Firefox preferences. ' + + 'Use restart_firefox with env parameter to enable.' + ); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + // Get chrome contexts + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + + const contexts = result.contexts || []; + if (contexts.length === 0) { + throw new Error( + 'No chrome contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + ); + } + + const driver = firefox.getDriver(); + const chromeContextId = contexts[0].context; + + // Remember current context + const originalContextId = firefox.getCurrentContextId(); + + try { + // Switch to chrome context + await driver.switchTo().window(chromeContextId); + await driver.setContext('chrome'); + + const results: string[] = []; + const errors: string[] = []; + + // Read each preference + for (const name of names) { + try { + // Use getPrefType to determine how to read the pref + const script = ` + (function() { + const type = Services.prefs.getPrefType(${JSON.stringify(name)}); + if (type === Services.prefs.PREF_INVALID) { + return { exists: false }; + } else if (type === Services.prefs.PREF_BOOL) { + return { exists: true, value: Services.prefs.getBoolPref(${JSON.stringify(name)}) }; + } else if (type === Services.prefs.PREF_INT) { + return { exists: true, value: Services.prefs.getIntPref(${JSON.stringify(name)}) }; + } else { + return { exists: true, value: Services.prefs.getStringPref(${JSON.stringify(name)}) }; + } + })() + `; + const prefResult = (await driver.executeScript(`return ${script}`)) as { + exists: boolean; + value?: unknown; + }; + + if (prefResult.exists) { + results.push(` ${name} = ${JSON.stringify(prefResult.value)}`); + } else { + results.push(` ${name} = (not set)`); + } + } catch (error) { + errors.push(` ${name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const output: string[] = []; + if (results.length > 0) { + output.push(`📋 Firefox Preferences:`); + output.push(...results); + } + if (errors.length > 0) { + output.push(`\n⚠ Failed to read ${errors.length} preference(s):`); + output.push(...errors); + } + + return successResponse(output.join('\n')); + } finally { + // Restore content context + try { + await driver.setContext('content'); + if (originalContextId) { + await driver.switchTo().window(originalContextId); + } + } catch { + // Ignore errors restoring context + } + } + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + return errorResponse( + new Error( + 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' + ) + ); + } + return errorResponse(error as Error); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 750b2e2..dffd2dd 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -100,3 +100,11 @@ export { handleSelectChromeContext, handleEvaluateChromeScript, } from './chrome-context.js'; + +// Firefox preferences tools +export { + setFirefoxPrefsTool, + getFirefoxPrefsTool, + handleSetFirefoxPrefs, + handleGetFirefoxPrefs, +} from './firefox-prefs.js'; diff --git a/tests/cli/prefs-parsing.test.ts b/tests/cli/prefs-parsing.test.ts new file mode 100644 index 0000000..f50aad0 --- /dev/null +++ b/tests/cli/prefs-parsing.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for preference parsing (parsePrefs function) and CLI --pref option + */ + +import { describe, it, expect } from 'vitest'; +import { parsePrefs, parseArguments } from '../../src/cli.js'; + +describe('parsePrefs', () => { + // Step 1.1 + it('should return empty object for undefined input', () => { + expect(parsePrefs(undefined)).toEqual({}); + }); + + // Step 1.2 + it('should return empty object for empty array', () => { + expect(parsePrefs([])).toEqual({}); + }); + + // Step 1.3 + it('should parse simple string preference', () => { + expect(parsePrefs(['some.pref=value'])).toEqual({ 'some.pref': 'value' }); + }); + + // Step 1.4 + it('should parse boolean true', () => { + expect(parsePrefs(['some.pref=true'])).toEqual({ 'some.pref': true }); + }); + + // Step 1.5 + it('should parse boolean false', () => { + expect(parsePrefs(['some.pref=false'])).toEqual({ 'some.pref': false }); + }); + + // Step 1.6 + it('should parse integer', () => { + expect(parsePrefs(['some.pref=42'])).toEqual({ 'some.pref': 42 }); + }); + + // Step 1.7 + it('should parse negative integer', () => { + expect(parsePrefs(['some.pref=-5'])).toEqual({ 'some.pref': -5 }); + }); + + // Step 1.8 + it('should keep float as string (Firefox has no float pref)', () => { + expect(parsePrefs(['some.pref=3.14'])).toEqual({ 'some.pref': '3.14' }); + }); + + // Step 1.9 + it('should handle value containing equals sign', () => { + expect(parsePrefs(['url=https://x.com?a=b'])).toEqual({ + url: 'https://x.com?a=b', + }); + }); + + // Step 1.10 + it('should skip malformed entries', () => { + expect(parsePrefs(['malformed', 'valid=value'])).toEqual({ valid: 'value' }); + }); + + // Step 1.11 + it('should handle empty value as empty string', () => { + expect(parsePrefs(['some.pref='])).toEqual({ 'some.pref': '' }); + }); + + // Multiple prefs + it('should parse multiple preferences', () => { + expect( + parsePrefs([ + 'bool.pref=true', + 'int.pref=42', + 'string.pref=hello', + ]) + ).toEqual({ + 'bool.pref': true, + 'int.pref': 42, + 'string.pref': 'hello', + }); + }); +}); + +describe('CLI --pref option', () => { + // Step 2.1 + it('should accept --pref argument', () => { + const args = parseArguments('1.0.0', ['node', 'script', '--pref', 'test=value']); + expect(args.pref).toContain('test=value'); + }); + + // Step 2.2 + it('should accept -p alias', () => { + const args = parseArguments('1.0.0', ['node', 'script', '-p', 'test=value']); + expect(args.pref).toBeDefined(); + expect(args.pref).toContain('test=value'); + }); + + // Multiple prefs via CLI + it('should accept multiple --pref arguments', () => { + const args = parseArguments('1.0.0', [ + 'node', + 'script', + '--pref', + 'pref1=value1', + '--pref', + 'pref2=value2', + ]); + expect(args.pref).toContain('pref1=value1'); + expect(args.pref).toContain('pref2=value2'); + }); +}); diff --git a/tests/firefox/core-prefs.test.ts b/tests/firefox/core-prefs.test.ts new file mode 100644 index 0000000..16137ea --- /dev/null +++ b/tests/firefox/core-prefs.test.ts @@ -0,0 +1,490 @@ +/** + * Tests for FirefoxCore applyPreferences method + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the index module to prevent actual Firefox connection +const mockGetFirefox = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/index.js', () => ({ + getFirefox: mockGetFirefox, +})); + +describe('FirefoxCore applyPreferences', () => { + const mockExecuteScript = vi.fn(); + const mockSetContext = vi.fn(); + const mockSwitchToWindow = vi.fn(); + const mockSendBiDiCommand = vi.fn(); + const mockGetDriver = vi.fn(); + const mockGetWindowHandle = vi.fn(); + + let originalEnv: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + + // Store original env + originalEnv = process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + + // Setup mock driver + mockGetDriver.mockReturnValue({ + switchTo: () => ({ + window: mockSwitchToWindow, + }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + getWindowHandle: mockGetWindowHandle, + }); + + mockGetWindowHandle.mockResolvedValue('content-context-id'); + }); + + afterEach(() => { + // Restore env + if (originalEnv !== undefined) { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = originalEnv; + } else { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + } + }); + + // Step 6.1 + it('should return early if no prefs', async () => { + // Mock selenium-webdriver + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + // Create core with no prefs + const core = new FirefoxCore({ headless: true }); + + // Mock the driver as already connected + (core as any).driver = mockGetDriver(); + + // Call applyPreferences - should not throw, should not call BiDi + await core.applyPreferences(); + + // Should not have called sendBiDiCommand since no prefs + expect(mockSendBiDiCommand).not.toHaveBeenCalled(); + }); + + // Step 6.2 + it('should throw if MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not set', async () => { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + const core = new FirefoxCore({ + headless: true, + prefs: { 'test.pref': 'value' }, + }); + + // Mock the driver as connected + (core as any).driver = mockGetDriver(); + + await expect(core.applyPreferences()).rejects.toThrow('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS'); + }); + + // Step 6.3 & 6.4 + it('should get chrome contexts via BiDi', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + // Setup sendBiDiCommand mock + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + getBidi: vi.fn().mockResolvedValue({ + socket: { + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + }, + }), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + const core = new FirefoxCore({ + headless: true, + prefs: { 'test.pref': 'value' }, + }); + + // Mock internals + (core as any).driver = mockGetDriver(); + (core as any).sendBiDiCommand = mockSendBiDiCommand; + (core as any).currentContextId = 'content-context-id'; + + await core.applyPreferences(); + + expect(mockSendBiDiCommand).toHaveBeenCalledWith('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + }); + + it('should throw if no chrome contexts available', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + const core = new FirefoxCore({ + headless: true, + prefs: { 'test.pref': 'value' }, + }); + + (core as any).driver = mockGetDriver(); + (core as any).sendBiDiCommand = mockSendBiDiCommand; + + await expect(core.applyPreferences()).rejects.toThrow('No chrome contexts'); + }); + + // Step 6.5 & 6.6 + it('should switch to chrome context and execute pref scripts', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + const core = new FirefoxCore({ + headless: true, + prefs: { + 'bool.pref': true, + 'int.pref': 42, + 'string.pref': 'hello', + }, + }); + + (core as any).driver = mockGetDriver(); + (core as any).sendBiDiCommand = mockSendBiDiCommand; + (core as any).currentContextId = 'content-context-id'; + + await core.applyPreferences(); + + // Should have switched to chrome context + expect(mockSwitchToWindow).toHaveBeenCalledWith('chrome-context-id'); + expect(mockSetContext).toHaveBeenCalledWith('chrome'); + + // Should have executed scripts for each pref + expect(mockExecuteScript).toHaveBeenCalledTimes(3); + expect(mockExecuteScript).toHaveBeenCalledWith( + 'Services.prefs.setBoolPref("bool.pref", true)' + ); + expect(mockExecuteScript).toHaveBeenCalledWith( + 'Services.prefs.setIntPref("int.pref", 42)' + ); + expect(mockExecuteScript).toHaveBeenCalledWith( + 'Services.prefs.setStringPref("string.pref", "hello")' + ); + }); + + // Step 6.7 + it('should restore content context in finally block', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + // Make executeScript throw to test finally block + mockExecuteScript.mockRejectedValue(new Error('Script error')); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + const core = new FirefoxCore({ + headless: true, + prefs: { 'test.pref': 'value' }, + }); + + (core as any).driver = mockGetDriver(); + (core as any).sendBiDiCommand = mockSendBiDiCommand; + (core as any).currentContextId = 'content-context-id'; + + // Should complete even with errors (continues on per-pref errors) + await core.applyPreferences(); + + // Should have restored content context even after error + expect(mockSetContext).toHaveBeenLastCalledWith('content'); + expect(mockSwitchToWindow).toHaveBeenLastCalledWith('content-context-id'); + }); + + // Step 10.1 - connect() integration + it('should call applyPreferences when prefs configured in connect()', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + switchTo: () => ({ + window: mockSwitchToWindow, + }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + getBidi: vi.fn().mockResolvedValue({ + socket: { + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + }, + }), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + const core = new FirefoxCore({ + headless: true, + prefs: { 'test.pref': 'value' }, + }); + + // Spy on applyPreferences + const applyPrefsSpy = vi.spyOn(core, 'applyPreferences').mockResolvedValue(); + + await core.connect(); + + // applyPreferences should have been called during connect + expect(applyPrefsSpy).toHaveBeenCalled(); + }); + + // Step 6.8 + it('should continue on per-pref errors and log failures', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + // First pref fails, second succeeds + mockExecuteScript + .mockRejectedValueOnce(new Error('First pref error')) + .mockResolvedValueOnce(undefined); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + Options: vi.fn(() => ({ + enableBidi: vi.fn(), + addArguments: vi.fn(), + setBinary: vi.fn(), + })), + ServiceBuilder: vi.fn(() => ({ + setStdio: vi.fn(), + })), + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Builder: vi.fn(() => ({ + forBrowser: vi.fn().mockReturnThis(), + setFirefoxOptions: vi.fn().mockReturnThis(), + setFirefoxService: vi.fn().mockReturnThis(), + build: vi.fn().mockResolvedValue({ + getWindowHandle: mockGetWindowHandle, + get: vi.fn().mockResolvedValue(undefined), + }), + })), + Browser: { FIREFOX: 'firefox' }, + })); + + const { FirefoxCore } = await import('../../src/firefox/core.js'); + + const core = new FirefoxCore({ + headless: true, + prefs: { + 'failing.pref': 'will fail', + 'success.pref': 'will succeed', + }, + }); + + (core as any).driver = mockGetDriver(); + (core as any).sendBiDiCommand = mockSendBiDiCommand; + (core as any).currentContextId = 'content-context-id'; + + // Should not throw - errors are collected + await core.applyPreferences(); + + // Both prefs should have been attempted + expect(mockExecuteScript).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/firefox/pref-utils.test.ts b/tests/firefox/pref-utils.test.ts new file mode 100644 index 0000000..ae5c0ac --- /dev/null +++ b/tests/firefox/pref-utils.test.ts @@ -0,0 +1,45 @@ +/** + * Tests for Firefox preference utilities + */ + +import { describe, it, expect } from 'vitest'; +import { generatePrefScript } from '../../src/firefox/pref-utils.js'; + +describe('generatePrefScript', () => { + // Step 4.1 + it('should generate setBoolPref for true', () => { + expect(generatePrefScript('p', true)).toBe('Services.prefs.setBoolPref("p", true)'); + }); + + // Step 4.2 + it('should generate setBoolPref for false', () => { + expect(generatePrefScript('p', false)).toBe('Services.prefs.setBoolPref("p", false)'); + }); + + // Step 4.3 + it('should generate setIntPref for number', () => { + expect(generatePrefScript('p', 42)).toBe('Services.prefs.setIntPref("p", 42)'); + }); + + // Step 4.4 + it('should generate setStringPref for string', () => { + expect(generatePrefScript('p', 'v')).toBe('Services.prefs.setStringPref("p", "v")'); + }); + + // Step 4.5 + it('should escape quotes in values', () => { + expect(generatePrefScript('p', 'a"b')).toBe('Services.prefs.setStringPref("p", "a\\"b")'); + }); + + it('should escape quotes in preference names', () => { + expect(generatePrefScript('p"ref', 'v')).toBe('Services.prefs.setStringPref("p\\"ref", "v")'); + }); + + it('should handle negative numbers', () => { + expect(generatePrefScript('p', -10)).toBe('Services.prefs.setIntPref("p", -10)'); + }); + + it('should handle empty string value', () => { + expect(generatePrefScript('p', '')).toBe('Services.prefs.setStringPref("p", "")'); + }); +}); diff --git a/tests/firefox/types.test.ts b/tests/firefox/types.test.ts new file mode 100644 index 0000000..0df5a2a --- /dev/null +++ b/tests/firefox/types.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for Firefox type definitions + */ + +import { describe, it, expect } from 'vitest'; +import type { FirefoxLaunchOptions } from '../../src/firefox/types.js'; + +describe('FirefoxLaunchOptions', () => { + // Step 3.1 + it('should accept prefs field', () => { + const options: FirefoxLaunchOptions = { + headless: true, + prefs: { 'a': 'b', 'num': 42, 'bool': true }, + }; + expect(options.prefs).toBeDefined(); + expect(options.prefs).toEqual({ 'a': 'b', 'num': 42, 'bool': true }); + }); + + it('should accept empty prefs object', () => { + const options: FirefoxLaunchOptions = { + headless: true, + prefs: {}, + }; + expect(options.prefs).toEqual({}); + }); + + it('should allow prefs to be undefined', () => { + const options: FirefoxLaunchOptions = { + headless: true, + }; + expect(options.prefs).toBeUndefined(); + }); +}); diff --git a/tests/tools/firefox-management.test.ts b/tests/tools/firefox-management.test.ts index 2f49615..d2ab214 100644 --- a/tests/tools/firefox-management.test.ts +++ b/tests/tools/firefox-management.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { restartFirefoxTool } from '../../src/tools/firefox-management.js'; +import { restartFirefoxTool, getFirefoxInfoTool } from '../../src/tools/firefox-management.js'; // Create mock functions that will be used in the hoisted mock const mockSetNextLaunchOptions = vi.hoisted(() => vi.fn()); @@ -14,12 +14,14 @@ const mockArgs = vi.hoisted(() => ({ profilePath: undefined as string | undefined, })); +const mockGetFirefox = vi.hoisted(() => vi.fn()); + vi.mock('../../src/index.js', () => ({ args: mockArgs, getFirefoxIfRunning: () => mockGetFirefoxIfRunning(), setNextLaunchOptions: (opts: unknown) => mockSetNextLaunchOptions(opts), resetFirefox: () => mockResetFirefox(), - getFirefox: vi.fn(), + getFirefox: () => mockGetFirefox(), })); describe('Firefox Management Tools', () => { @@ -32,6 +34,15 @@ describe('Firefox Management Tools', () => { expect(properties.profilePath.type).toBe('string'); expect(properties.profilePath.description).toContain('profile'); }); + + // Step 7.1 + it('should have prefs in input schema properties', () => { + const { properties } = restartFirefoxTool.inputSchema as { + properties: Record; + }; + expect(properties.prefs).toBeDefined(); + expect(properties.prefs.type).toBe('object'); + }); }); describe('handleRestartFirefox', () => { @@ -150,6 +161,102 @@ describe('Firefox Management Tools', () => { expect(text).toContain('Profile'); expect(text).toContain('/new/profile'); }); + + // Step 7.2 + it('should merge prefs into launch options', async () => { + mockFirefoxInstance.getOptions.mockReturnValue({ + firefoxPath: '/current/firefox', + profilePath: '/current/profile', + headless: false, + env: {}, + prefs: { 'existing.pref': 'old' }, + }); + + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({ + prefs: { 'new.pref': 'value', 'existing.pref': 'new' }, + }); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + prefs: { 'existing.pref': 'new', 'new.pref': 'value' }, + }) + ); + }); + + it('should preserve existing prefs when none provided', async () => { + mockFirefoxInstance.getOptions.mockReturnValue({ + firefoxPath: '/current/firefox', + profilePath: '/current/profile', + headless: false, + env: {}, + prefs: { 'existing.pref': 'value' }, + }); + + const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js'); + + await handleRestartFirefox({}); + + expect(mockSetNextLaunchOptions).toHaveBeenCalledWith( + expect.objectContaining({ + prefs: { 'existing.pref': 'value' }, + }) + ); + }); + }); + }); + + describe('handleGetFirefoxInfo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Step 8.1 + it('should include prefs in output when configured', async () => { + const mockFirefoxWithPrefs = { + getOptions: vi.fn().mockReturnValue({ + firefoxPath: '/path/to/firefox', + headless: true, + prefs: { + 'bool.pref': true, + 'int.pref': 42, + 'string.pref': 'hello', + }, + }), + getLogFilePath: vi.fn().mockReturnValue(undefined), + }; + + mockGetFirefox.mockResolvedValue(mockFirefoxWithPrefs); + + const { handleGetFirefoxInfo } = await import('../../src/tools/firefox-management.js'); + + const result = await handleGetFirefoxInfo({}); + + const text = result.content[0].text; + expect(text).toContain('Preferences:'); + expect(text).toContain('bool.pref = true'); + expect(text).toContain('int.pref = 42'); + expect(text).toContain('string.pref = "hello"'); + }); + + it('should not show preferences section when none configured', async () => { + const mockFirefoxNoPrefs = { + getOptions: vi.fn().mockReturnValue({ + firefoxPath: '/path/to/firefox', + headless: true, + }), + getLogFilePath: vi.fn().mockReturnValue(undefined), + }; + + mockGetFirefox.mockResolvedValue(mockFirefoxNoPrefs); + + const { handleGetFirefoxInfo } = await import('../../src/tools/firefox-management.js'); + + const result = await handleGetFirefoxInfo({}); + + const text = result.content[0].text; + expect(text).not.toContain('Preferences:'); }); }); }); diff --git a/tests/tools/firefox-prefs.test.ts b/tests/tools/firefox-prefs.test.ts new file mode 100644 index 0000000..3996c03 --- /dev/null +++ b/tests/tools/firefox-prefs.test.ts @@ -0,0 +1,309 @@ +/** + * Tests for Firefox preferences tools + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + setFirefoxPrefsTool, + getFirefoxPrefsTool, + handleSetFirefoxPrefs, + handleGetFirefoxPrefs, +} from '../../src/tools/firefox-prefs.js'; + +// Mock the index module +const mockGetFirefox = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/index.js', () => ({ + getFirefox: () => mockGetFirefox(), +})); + +describe('Firefox Prefs Tool Definitions', () => { + describe('setFirefoxPrefsTool', () => { + // Step 5.1 + it('should have correct name', () => { + expect(setFirefoxPrefsTool.name).toBe('set_firefox_prefs'); + }); + + // Step 5.2 + it('should require prefs parameter', () => { + const schema = setFirefoxPrefsTool.inputSchema as { + required?: string[]; + }; + expect(schema.required).toContain('prefs'); + }); + + it('should have description', () => { + expect(setFirefoxPrefsTool.description).toBeDefined(); + expect(setFirefoxPrefsTool.description.length).toBeGreaterThan(0); + }); + + it('should define prefs as object type', () => { + const schema = setFirefoxPrefsTool.inputSchema as { + properties?: Record; + }; + expect(schema.properties?.prefs?.type).toBe('object'); + }); + }); + + describe('getFirefoxPrefsTool', () => { + // Step 5.3 + it('should have correct name', () => { + expect(getFirefoxPrefsTool.name).toBe('get_firefox_prefs'); + }); + + // Step 5.4 + it('should require names parameter', () => { + const schema = getFirefoxPrefsTool.inputSchema as { + required?: string[]; + }; + expect(schema.required).toContain('names'); + }); + + it('should have description', () => { + expect(getFirefoxPrefsTool.description).toBeDefined(); + expect(getFirefoxPrefsTool.description.length).toBeGreaterThan(0); + }); + + it('should define names as array type', () => { + const schema = getFirefoxPrefsTool.inputSchema as { + properties?: Record; + }; + expect(schema.properties?.names?.type).toBe('array'); + }); + }); +}); + +describe('Firefox Prefs Tool Handlers', () => { + const mockExecuteScript = vi.fn(); + const mockSetContext = vi.fn(); + const mockSwitchToWindow = vi.fn(); + const mockSendBiDiCommand = vi.fn(); + + let originalEnv: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + originalEnv = process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = originalEnv; + } else { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + } + }); + + describe('handleSetFirefoxPrefs', () => { + it('should return error when prefs parameter is missing', async () => { + const result = await handleSetFirefoxPrefs({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('prefs parameter is required'); + }); + + it('should return success when prefs is empty', async () => { + const result = await handleSetFirefoxPrefs({ prefs: {} }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('No preferences to set'); + }); + + it('should return error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is not set', async () => { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + + const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS'); + }); + + it('should set preferences successfully', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn().mockReturnValue({ + switchTo: () => ({ window: mockSwitchToWindow }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + }), + getCurrentContextId: vi.fn().mockReturnValue('content-context-id'), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleSetFirefoxPrefs({ + prefs: { 'test.bool': true, 'test.int': 42, 'test.string': 'hello' }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('Set 3 preference(s)'); + expect(mockExecuteScript).toHaveBeenCalledTimes(3); + expect(mockExecuteScript).toHaveBeenCalledWith( + 'Services.prefs.setBoolPref("test.bool", true)' + ); + expect(mockExecuteScript).toHaveBeenCalledWith( + 'Services.prefs.setIntPref("test.int", 42)' + ); + expect(mockExecuteScript).toHaveBeenCalledWith( + 'Services.prefs.setStringPref("test.string", "hello")' + ); + }); + + it('should handle partial failures gracefully', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + mockExecuteScript + .mockResolvedValueOnce(undefined) // first pref succeeds + .mockRejectedValueOnce(new Error('Pref error')); // second fails + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn().mockReturnValue({ + switchTo: () => ({ window: mockSwitchToWindow }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + }), + getCurrentContextId: vi.fn().mockReturnValue('content-context-id'), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleSetFirefoxPrefs({ + prefs: { 'good.pref': 'value', 'bad.pref': 'value' }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('Set 1 preference(s)'); + expect(result.content[0].text).toContain('Failed to set 1 preference(s)'); + }); + + it('should return error when no chrome contexts available', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn(), + getCurrentContextId: vi.fn(), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('No chrome contexts'); + }); + }); + + describe('handleGetFirefoxPrefs', () => { + it('should return error when names parameter is missing', async () => { + const result = await handleGetFirefoxPrefs({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('names parameter is required'); + }); + + it('should return error when names is empty array', async () => { + const result = await handleGetFirefoxPrefs({ names: [] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('names parameter is required'); + }); + + it('should return error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is not set', async () => { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + + const result = await handleGetFirefoxPrefs({ names: ['test.pref'] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS'); + }); + + it('should get preferences successfully', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + mockExecuteScript.mockResolvedValue({ exists: true, value: 'test-value' }); + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn().mockReturnValue({ + switchTo: () => ({ window: mockSwitchToWindow }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + }), + getCurrentContextId: vi.fn().mockReturnValue('content-context-id'), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleGetFirefoxPrefs({ names: ['test.pref'] }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('Firefox Preferences'); + expect(result.content[0].text).toContain('test.pref'); + expect(result.content[0].text).toContain('"test-value"'); + }); + + it('should handle non-existent preferences', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + + mockExecuteScript.mockResolvedValue({ exists: false }); + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn().mockReturnValue({ + switchTo: () => ({ window: mockSwitchToWindow }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + }), + getCurrentContextId: vi.fn().mockReturnValue('content-context-id'), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleGetFirefoxPrefs({ names: ['nonexistent.pref'] }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('(not set)'); + }); + + it('should return error when no chrome contexts available', async () => { + process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; + + mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn(), + getCurrentContextId: vi.fn(), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleGetFirefoxPrefs({ names: ['test.pref'] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('No chrome contexts'); + }); + }); +}); From 66c6a1aa108e58c405a75680072f63ea0764e213 Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Thu, 29 Jan 2026 21:23:59 -0800 Subject: [PATCH 09/20] fix(firefox): Wait for WebSocket ready before sending BiDi commands After Firefox launches, sendBiDiCommand() was failing with "WebSocket is not open: readyState 0 (CONNECTING)" errors because it tried to send commands before the BiDi WebSocket connection was fully established. Add waitForWebSocketOpen() helper that: - Returns immediately if WebSocket is already OPEN (readyState 1) - Waits for 'open' event if CONNECTING (readyState 0) with 5s timeout - Throws immediately if CLOSING or CLOSED (readyState 2 or 3) --- src/firefox/core.ts | 36 +++++++- tests/firefox/core.test.ts | 169 +++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/src/firefox/core.ts b/src/firefox/core.ts index bfc6fbe..2d58b98 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -744,6 +744,35 @@ export class FirefoxCore { } } + /** + * Wait for WebSocket to be in OPEN state + */ + private async waitForWebSocketOpen(ws: any, timeout: number = 5000): Promise { + // Already open + if (ws.readyState === 1) { + return; + } + + // Still connecting - wait for open event with timeout + if (ws.readyState === 0) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + ws.off('open', onOpen); + reject(new Error('Timeout waiting for WebSocket to open')); + }, timeout); + + const onOpen = () => { + clearTimeout(timeoutId); + ws.off('open', onOpen); + resolve(); + }; + ws.on('open', onOpen); + }); + } + + throw new Error(`WebSocket is not open: readyState ${ws.readyState}`); + } + /** * Send raw BiDi command and get response */ @@ -753,11 +782,14 @@ export class FirefoxCore { } const bidi = await this.driver.getBidi(); + const ws: any = bidi.socket; + + // Wait for WebSocket to be ready before sending + await this.waitForWebSocketOpen(ws); + const id = Math.floor(Math.random() * 1000000); return new Promise((resolve, reject) => { - const ws: any = bidi.socket; - const messageHandler = (data: any) => { try { const payload = JSON.parse(data.toString()); diff --git a/tests/firefox/core.test.ts b/tests/firefox/core.test.ts index 28711cd..aece07c 100644 --- a/tests/firefox/core.test.ts +++ b/tests/firefox/core.test.ts @@ -137,3 +137,172 @@ describe('FirefoxCore connect() profile handling', () => { expect(mockAddArguments).toHaveBeenCalledWith('--profile', profilePath); }); }); + +// Tests for sendBiDiCommand WebSocket handling +describe('FirefoxCore sendBiDiCommand WebSocket readiness', () => { + it('should wait for WebSocket to open when in CONNECTING state', async () => { + const { FirefoxCore } = await import('@/firefox/core.js'); + + const core = new FirefoxCore({ headless: true }); + + // Track event listeners and send calls + const eventListeners: Record = {}; + const mockSend = vi.fn(); + + // Mock WebSocket in CONNECTING state (readyState 0) + const mockWs = { + readyState: 0, // CONNECTING + send: mockSend, + on: vi.fn((event: string, handler: Function) => { + if (!eventListeners[event]) eventListeners[event] = []; + eventListeners[event].push(handler); + }), + off: vi.fn(), + }; + + // Mock driver with BiDi socket + (core as any).driver = { + getBidi: vi.fn().mockResolvedValue({ + socket: mockWs, + }), + }; + + // Start the command (don't await yet) + const commandPromise = core.sendBiDiCommand('test.method', { foo: 'bar' }); + + // Give the async code a tick to execute + await new Promise((resolve) => setTimeout(resolve, 10)); + + // ASSERT: send() should NOT have been called while still CONNECTING + expect(mockSend).not.toHaveBeenCalled(); + + // ASSERT: should have registered an 'open' event listener + expect(mockWs.on).toHaveBeenCalledWith('open', expect.any(Function)); + + // Now simulate WebSocket becoming OPEN + mockWs.readyState = 1; // OPEN + if (eventListeners['open']) { + eventListeners['open'].forEach((handler) => handler()); + } + + // Give another tick for send to be called + await new Promise((resolve) => setTimeout(resolve, 10)); + + // ASSERT: send() should now have been called + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + expect.stringContaining('"method":"test.method"') + ); + + // Simulate response to complete the promise + if (eventListeners['message']) { + const sentCommand = JSON.parse(mockSend.mock.calls[0][0]); + eventListeners['message'].forEach((handler) => + handler(JSON.stringify({ id: sentCommand.id, result: { success: true } })) + ); + } + + const result = await commandPromise; + expect(result).toEqual({ success: true }); + }); + + it('should timeout if WebSocket never opens', async () => { + const { FirefoxCore } = await import('@/firefox/core.js'); + + const core = new FirefoxCore({ headless: true }); + + // Track event listeners + const eventListeners: Record = {}; + + // Mock WebSocket stuck in CONNECTING state (never opens) + const mockWs = { + readyState: 0, // CONNECTING - stays this way + send: vi.fn(), + on: vi.fn((event: string, handler: Function) => { + if (!eventListeners[event]) eventListeners[event] = []; + eventListeners[event].push(handler); + }), + off: vi.fn(), + }; + + // Mock driver with BiDi socket + (core as any).driver = { + getBidi: vi.fn().mockResolvedValue({ + socket: mockWs, + }), + }; + + // Access the private method directly to test with a short timeout + const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core); + + // ASSERT: should reject with timeout error (using 50ms timeout for fast test) + await expect(waitForWebSocketOpen(mockWs, 50)).rejects.toThrow( + /timeout.*websocket/i + ); + }); + + it('should throw error when WebSocket is CLOSING', async () => { + const { FirefoxCore } = await import('@/firefox/core.js'); + + const core = new FirefoxCore({ headless: true }); + + // Mock WebSocket in CLOSING state (readyState 2) + const mockWs = { + readyState: 2, // CLOSING + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + // Access the private method directly + const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core); + + // ASSERT: should throw immediately with descriptive error + await expect(waitForWebSocketOpen(mockWs)).rejects.toThrow( + /websocket is not open.*readystate 2/i + ); + }); + + it('should throw error when WebSocket is CLOSED', async () => { + const { FirefoxCore } = await import('@/firefox/core.js'); + + const core = new FirefoxCore({ headless: true }); + + // Mock WebSocket in CLOSED state (readyState 3) + const mockWs = { + readyState: 3, // CLOSED + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + // Access the private method directly + const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core); + + // ASSERT: should throw immediately with descriptive error + await expect(waitForWebSocketOpen(mockWs)).rejects.toThrow( + /websocket is not open.*readystate 3/i + ); + }); + + it('should proceed immediately when WebSocket is already OPEN', async () => { + const { FirefoxCore } = await import('@/firefox/core.js'); + + const core = new FirefoxCore({ headless: true }); + + // Mock WebSocket already in OPEN state (readyState 1) + const mockWs = { + readyState: 1, // OPEN + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + // Access the private method directly + const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core); + + // ASSERT: should resolve immediately without registering any listeners + await expect(waitForWebSocketOpen(mockWs)).resolves.toBeUndefined(); + expect(mockWs.on).not.toHaveBeenCalled(); + }); +}); From 3d9ba94f55c98afd4a86cc55bc7261951771bfd9 Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Thu, 29 Jan 2026 21:42:02 -0800 Subject: [PATCH 10/20] fix(firefox): Fix pref tools failing when env var passed via CLI The pref tools checked process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS before calling getFirefox(), but the env var is only set in process.env during getFirefox() -> FirefoxCore.connect(). This caused tools to fail when launched with --env MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1. Remove early env checks and rely on the existing "No chrome contexts available" error which already provides helpful guidance. --- src/tools/firefox-prefs.ts | 16 ------- tests/tools/firefox-prefs.test.ts | 73 ++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/tools/firefox-prefs.ts b/src/tools/firefox-prefs.ts index 2fd422c..bc9c4b4 100644 --- a/src/tools/firefox-prefs.ts +++ b/src/tools/firefox-prefs.ts @@ -45,14 +45,6 @@ export async function handleSetFirefoxPrefs(args: unknown): Promise { expect(result.content[0].text).toContain('No preferences to set'); }); - it('should return error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is not set', async () => { + it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no chrome contexts', async () => { delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no chrome contexts are available + mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn(), + getCurrentContextId: vi.fn(), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } }); expect(result.isError).toBe(true); @@ -205,6 +216,29 @@ describe('Firefox Prefs Tool Handlers', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('No chrome contexts'); }); + + it('should call getFirefox even when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not in process.env', async () => { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn().mockReturnValue({ + switchTo: () => ({ window: mockSwitchToWindow }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + }), + getCurrentContextId: vi.fn().mockReturnValue('content-context-id'), + }; + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } }); + + expect(mockGetFirefox).toHaveBeenCalled(); + expect(result.isError).toBeUndefined(); + }); }); describe('handleGetFirefoxPrefs', () => { @@ -222,9 +256,20 @@ describe('Firefox Prefs Tool Handlers', () => { expect(result.content[0].text).toContain('names parameter is required'); }); - it('should return error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is not set', async () => { + it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no chrome contexts', async () => { delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no chrome contexts are available + mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); + + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn(), + getCurrentContextId: vi.fn(), + }; + + mockGetFirefox.mockResolvedValue(mockFirefox); + const result = await handleGetFirefoxPrefs({ names: ['test.pref'] }); expect(result.isError).toBe(true); @@ -305,5 +350,29 @@ describe('Firefox Prefs Tool Handlers', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('No chrome contexts'); }); + + it('should call getFirefox even when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not in process.env', async () => { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + + mockSendBiDiCommand.mockResolvedValue({ + contexts: [{ context: 'chrome-context-id' }], + }); + mockExecuteScript.mockResolvedValue({ exists: true, value: 'test-value' }); + const mockFirefox = { + sendBiDiCommand: mockSendBiDiCommand, + getDriver: vi.fn().mockReturnValue({ + switchTo: () => ({ window: mockSwitchToWindow }), + setContext: mockSetContext, + executeScript: mockExecuteScript, + }), + getCurrentContextId: vi.fn().mockReturnValue('content-context-id'), + }; + mockGetFirefox.mockResolvedValue(mockFirefox); + + const result = await handleGetFirefoxPrefs({ names: ['test.pref'] }); + + expect(mockGetFirefox).toHaveBeenCalled(); + expect(result.isError).toBeUndefined(); + }); }); }); From 5ba9407c9711e99ea6b9fa568aa863d9d7d9298e Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Mon, 2 Feb 2026 18:59:17 -0800 Subject: [PATCH 11/20] chore(tests): Remove plan step comments Clean up test files by removing implementation-tracking comments that referenced plan steps (e.g., "// Step 1.1"). These were added during development and are no longer needed. --- tests/cli/prefs-parsing.test.ts | 13 ------------- tests/firefox/core-prefs.test.ts | 7 ------- tests/firefox/pref-utils.test.ts | 5 ----- tests/firefox/types.test.ts | 1 - tests/tools/firefox-management.test.ts | 3 --- tests/tools/firefox-prefs.test.ts | 4 ---- 6 files changed, 33 deletions(-) diff --git a/tests/cli/prefs-parsing.test.ts b/tests/cli/prefs-parsing.test.ts index f50aad0..e126a83 100644 --- a/tests/cli/prefs-parsing.test.ts +++ b/tests/cli/prefs-parsing.test.ts @@ -6,59 +6,48 @@ import { describe, it, expect } from 'vitest'; import { parsePrefs, parseArguments } from '../../src/cli.js'; describe('parsePrefs', () => { - // Step 1.1 it('should return empty object for undefined input', () => { expect(parsePrefs(undefined)).toEqual({}); }); - // Step 1.2 it('should return empty object for empty array', () => { expect(parsePrefs([])).toEqual({}); }); - // Step 1.3 it('should parse simple string preference', () => { expect(parsePrefs(['some.pref=value'])).toEqual({ 'some.pref': 'value' }); }); - // Step 1.4 it('should parse boolean true', () => { expect(parsePrefs(['some.pref=true'])).toEqual({ 'some.pref': true }); }); - // Step 1.5 it('should parse boolean false', () => { expect(parsePrefs(['some.pref=false'])).toEqual({ 'some.pref': false }); }); - // Step 1.6 it('should parse integer', () => { expect(parsePrefs(['some.pref=42'])).toEqual({ 'some.pref': 42 }); }); - // Step 1.7 it('should parse negative integer', () => { expect(parsePrefs(['some.pref=-5'])).toEqual({ 'some.pref': -5 }); }); - // Step 1.8 it('should keep float as string (Firefox has no float pref)', () => { expect(parsePrefs(['some.pref=3.14'])).toEqual({ 'some.pref': '3.14' }); }); - // Step 1.9 it('should handle value containing equals sign', () => { expect(parsePrefs(['url=https://x.com?a=b'])).toEqual({ url: 'https://x.com?a=b', }); }); - // Step 1.10 it('should skip malformed entries', () => { expect(parsePrefs(['malformed', 'valid=value'])).toEqual({ valid: 'value' }); }); - // Step 1.11 it('should handle empty value as empty string', () => { expect(parsePrefs(['some.pref='])).toEqual({ 'some.pref': '' }); }); @@ -80,13 +69,11 @@ describe('parsePrefs', () => { }); describe('CLI --pref option', () => { - // Step 2.1 it('should accept --pref argument', () => { const args = parseArguments('1.0.0', ['node', 'script', '--pref', 'test=value']); expect(args.pref).toContain('test=value'); }); - // Step 2.2 it('should accept -p alias', () => { const args = parseArguments('1.0.0', ['node', 'script', '-p', 'test=value']); expect(args.pref).toBeDefined(); diff --git a/tests/firefox/core-prefs.test.ts b/tests/firefox/core-prefs.test.ts index 16137ea..395a384 100644 --- a/tests/firefox/core-prefs.test.ts +++ b/tests/firefox/core-prefs.test.ts @@ -50,7 +50,6 @@ describe('FirefoxCore applyPreferences', () => { } }); - // Step 6.1 it('should return early if no prefs', async () => { // Mock selenium-webdriver vi.doMock('selenium-webdriver/firefox.js', () => ({ @@ -94,7 +93,6 @@ describe('FirefoxCore applyPreferences', () => { expect(mockSendBiDiCommand).not.toHaveBeenCalled(); }); - // Step 6.2 it('should throw if MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not set', async () => { delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; @@ -137,7 +135,6 @@ describe('FirefoxCore applyPreferences', () => { await expect(core.applyPreferences()).rejects.toThrow('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS'); }); - // Step 6.3 & 6.4 it('should get chrome contexts via BiDi', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; @@ -242,7 +239,6 @@ describe('FirefoxCore applyPreferences', () => { await expect(core.applyPreferences()).rejects.toThrow('No chrome contexts'); }); - // Step 6.5 & 6.6 it('should switch to chrome context and execute pref scripts', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; @@ -310,7 +306,6 @@ describe('FirefoxCore applyPreferences', () => { ); }); - // Step 6.7 it('should restore content context in finally block', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; @@ -366,7 +361,6 @@ describe('FirefoxCore applyPreferences', () => { expect(mockSwitchToWindow).toHaveBeenLastCalledWith('content-context-id'); }); - // Step 10.1 - connect() integration it('should call applyPreferences when prefs configured in connect()', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; @@ -428,7 +422,6 @@ describe('FirefoxCore applyPreferences', () => { expect(applyPrefsSpy).toHaveBeenCalled(); }); - // Step 6.8 it('should continue on per-pref errors and log failures', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; diff --git a/tests/firefox/pref-utils.test.ts b/tests/firefox/pref-utils.test.ts index ae5c0ac..5a55879 100644 --- a/tests/firefox/pref-utils.test.ts +++ b/tests/firefox/pref-utils.test.ts @@ -6,27 +6,22 @@ import { describe, it, expect } from 'vitest'; import { generatePrefScript } from '../../src/firefox/pref-utils.js'; describe('generatePrefScript', () => { - // Step 4.1 it('should generate setBoolPref for true', () => { expect(generatePrefScript('p', true)).toBe('Services.prefs.setBoolPref("p", true)'); }); - // Step 4.2 it('should generate setBoolPref for false', () => { expect(generatePrefScript('p', false)).toBe('Services.prefs.setBoolPref("p", false)'); }); - // Step 4.3 it('should generate setIntPref for number', () => { expect(generatePrefScript('p', 42)).toBe('Services.prefs.setIntPref("p", 42)'); }); - // Step 4.4 it('should generate setStringPref for string', () => { expect(generatePrefScript('p', 'v')).toBe('Services.prefs.setStringPref("p", "v")'); }); - // Step 4.5 it('should escape quotes in values', () => { expect(generatePrefScript('p', 'a"b')).toBe('Services.prefs.setStringPref("p", "a\\"b")'); }); diff --git a/tests/firefox/types.test.ts b/tests/firefox/types.test.ts index 0df5a2a..4a1cb37 100644 --- a/tests/firefox/types.test.ts +++ b/tests/firefox/types.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect } from 'vitest'; import type { FirefoxLaunchOptions } from '../../src/firefox/types.js'; describe('FirefoxLaunchOptions', () => { - // Step 3.1 it('should accept prefs field', () => { const options: FirefoxLaunchOptions = { headless: true, diff --git a/tests/tools/firefox-management.test.ts b/tests/tools/firefox-management.test.ts index d2ab214..f8467ca 100644 --- a/tests/tools/firefox-management.test.ts +++ b/tests/tools/firefox-management.test.ts @@ -35,7 +35,6 @@ describe('Firefox Management Tools', () => { expect(properties.profilePath.description).toContain('profile'); }); - // Step 7.1 it('should have prefs in input schema properties', () => { const { properties } = restartFirefoxTool.inputSchema as { properties: Record; @@ -162,7 +161,6 @@ describe('Firefox Management Tools', () => { expect(text).toContain('/new/profile'); }); - // Step 7.2 it('should merge prefs into launch options', async () => { mockFirefoxInstance.getOptions.mockReturnValue({ firefoxPath: '/current/firefox', @@ -212,7 +210,6 @@ describe('Firefox Management Tools', () => { vi.clearAllMocks(); }); - // Step 8.1 it('should include prefs in output when configured', async () => { const mockFirefoxWithPrefs = { getOptions: vi.fn().mockReturnValue({ diff --git a/tests/tools/firefox-prefs.test.ts b/tests/tools/firefox-prefs.test.ts index f05eacc..375f161 100644 --- a/tests/tools/firefox-prefs.test.ts +++ b/tests/tools/firefox-prefs.test.ts @@ -19,12 +19,10 @@ vi.mock('../../src/index.js', () => ({ describe('Firefox Prefs Tool Definitions', () => { describe('setFirefoxPrefsTool', () => { - // Step 5.1 it('should have correct name', () => { expect(setFirefoxPrefsTool.name).toBe('set_firefox_prefs'); }); - // Step 5.2 it('should require prefs parameter', () => { const schema = setFirefoxPrefsTool.inputSchema as { required?: string[]; @@ -46,12 +44,10 @@ describe('Firefox Prefs Tool Definitions', () => { }); describe('getFirefoxPrefsTool', () => { - // Step 5.3 it('should have correct name', () => { expect(getFirefoxPrefsTool.name).toBe('get_firefox_prefs'); }); - // Step 5.4 it('should require names parameter', () => { const schema = getFirefoxPrefsTool.inputSchema as { required?: string[]; From aa7fcb65256665210c6ff64f30bde731866c46de Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Mon, 2 Feb 2026 19:04:12 -0800 Subject: [PATCH 12/20] docs: Add browser.ml.enable as example pref Highlight the browser.ml.enable preference as a practical example in both README and firefox-client.md. This pref is particularly relevant because RecommendedPreferences disables ML/AI features by default, making it essential for developing features like Smart Window with this MCP server. --- README.md | 2 ++ docs/firefox-client.md | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 24019bf..f348ce4 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ BiDi-dependent features (console events, network events) are not available in co > normally afterward. > **Note on `--pref`:** When Firefox runs in WebDriver BiDi mode, it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that modify browser behavior for testing. The `--pref` option allows overriding these defaults when needed (e.g., for Firefox development, debugging, or testing scenarios that require production-like behavior). +> +> **Example:** `--pref "browser.ml.enable=true"` enables Firefox's ML/AI features. This is essential when using this MCP server to develop or test AI-powered features like Smart Window, since RecommendedPreferences disables it by default. ## Tool overview diff --git a/docs/firefox-client.md b/docs/firefox-client.md index ef569b3..21c0ff4 100644 --- a/docs/firefox-client.md +++ b/docs/firefox-client.md @@ -166,23 +166,26 @@ When Firefox runs in WebDriver BiDi mode (automated testing), it applies [Recomm - Testing scenarios requiring production-like behavior - Enabling specific features disabled by RecommendedPreferences +**Example:** The `browser.ml.enable` preference controls Firefox's ML/AI features. RecommendedPreferences disables this by default, making it impossible to use this MCP server to develop or test AI-powered features like Smart Window without explicitly enabling it. + **Setting preferences:** At startup via CLI (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`): ```bash -npx firefox-devtools-mcp --pref "browser.cache.disk.enable=true" --pref "dom.webnotifications.enabled=true" +# Enable ML/AI features like Smart Window +npx firefox-devtools-mcp --pref "browser.ml.enable=true" ``` At runtime via tools: ```javascript -// Set preferences -await set_firefox_prefs({ prefs: { "browser.cache.disk.enable": true } }); +// Set preferences (e.g., enable ML features) +await set_firefox_prefs({ prefs: { "browser.ml.enable": true } }); // Get preference values -await get_firefox_prefs({ names: ["browser.cache.disk.enable", "dom.webnotifications.enabled"] }); +await get_firefox_prefs({ names: ["browser.ml.enable"] }); // Via restart_firefox -await restart_firefox({ prefs: { "browser.cache.disk.enable": true } }); +await restart_firefox({ prefs: { "browser.ml.enable": true } }); ``` **Note:** Preference tools require `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` environment variable. From 11713adccc152426c80f49b1c9f800bbe7831922 Mon Sep 17 00:00:00 2001 From: Ruhollah Majdoddin Date: Fri, 6 Feb 2026 11:21:27 +0100 Subject: [PATCH 13/20] Add webExtension.install and webExtension.uninstall tools Implement BiDi-native tools for installing and uninstalling Firefox extensions: - install_extension: Wraps webExtension.install BiDi command - Supports 3 types: archivePath (.xpi/.zip), base64, path (unpacked) - Firefox-specific moz:permanent parameter for signed extensions - uninstall_extension: Wraps webExtension.uninstall BiDi command Both tools provide straightforward access to Firefox's native WebDriver BiDi webExtension module functionality. --- src/index.ts | 8 +++ src/tools/index.ts | 8 +++ src/tools/webextension.ts | 133 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/tools/webextension.ts diff --git a/src/index.ts b/src/index.ts index e8d158b..9d4b086 100644 --- a/src/index.ts +++ b/src/index.ts @@ -216,6 +216,10 @@ const toolHandlers = new Map Promise { + try { + const { type, path, value, permanent } = args as { + type: 'archivePath' | 'base64' | 'path'; + path?: string; + value?: string; + permanent?: boolean; + }; + + if (!type) { + throw new Error('type parameter is required'); + } + + // Validate required fields based on type + if ((type === 'archivePath' || type === 'path') && !path) { + throw new Error(`path parameter is required for type "${type}"`); + } + if (type === 'base64' && !value) { + throw new Error('value parameter is required for type "base64"'); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + // Build extensionData parameter + const extensionData: Record = { type }; + if (path) { + extensionData.path = path; + } + if (value) { + extensionData.value = value; + } + + // Build BiDi command parameters + const params: Record = { extensionData }; + if (permanent !== undefined) { + params['moz:permanent'] = permanent; + } + + const result = await firefox.sendBiDiCommand('webExtension.install', params); + + const extensionId = result?.extension || result?.id || 'unknown'; + const installType = permanent ? 'permanent' : 'temporary'; + + return successResponse( + `✅ Extension installed (${installType}):\n ID: ${extensionId}\n Type: ${type}${path ? `\n Path: ${path}` : ''}` + ); + } catch (error) { + return errorResponse(error as Error); + } +} + +// ============================================================================ +// Tool: uninstall_extension +// ============================================================================ + +export const uninstallExtensionTool = { + name: 'uninstall_extension', + description: + 'Uninstall a Firefox extension using WebDriver BiDi webExtension.uninstall command. Requires the extension ID returned by install_extension or obtained from list_extensions.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Extension ID (e.g., "addon@example.com")', + }, + }, + required: ['id'], + }, +}; + +export async function handleUninstallExtension(args: unknown): Promise { + try { + const { id } = args as { id: string }; + + if (!id || typeof id !== 'string') { + throw new Error('id parameter is required and must be a string'); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + await firefox.sendBiDiCommand('webExtension.uninstall', { extension: id }); + + return successResponse(`✅ Extension uninstalled:\n ID: ${id}`); + } catch (error) { + return errorResponse(error as Error); + } +} From 8c50eef065dd24338ae6764da1c1a37a862b0178 Mon Sep 17 00:00:00 2001 From: Ruhollah Majdoddin Date: Fri, 6 Feb 2026 10:53:16 +0100 Subject: [PATCH 14/20] Add list_extensions tool for extension discovery Implements extension discovery using chrome-privileged AddonManager API as a workaround for missing webExtension.getExtensions BiDi command. Features: - Lists all installed Firefox extensions with UUIDs and metadata - Extracts mozExtensionHostname (UUID) for moz-extension:// URLs - Shows background scripts, manifest version, active status - Distinguishes system/built-in vs user-installed extensions - Supports filtering by: ids (exact), name (partial), isActive, isSystem - Filters applied in browser for efficiency Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable. --- src/index.ts | 2 + src/tools/index.ts | 4 +- src/tools/webextension.ts | 210 +++++++++++++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9d4b086..685e107 100644 --- a/src/index.ts +++ b/src/index.ts @@ -220,6 +220,7 @@ const toolHandlers = new Map Promise 0) { + lines.push(` Background scripts:`); + for (const script of ext.backgroundScripts) { + const scriptName = script.split('/').pop(); + lines.push(` ‱ ${scriptName}`); + } + } else { + lines.push(` Background scripts: (none)`); + } + } + + return lines.join('\n'); +} + +export async function handleListExtensions(args: unknown): Promise { + try { + const { ids, name, isActive, isSystem } = (args as { + ids?: string[]; + name?: string; + isActive?: boolean; + isSystem?: boolean; + }) || {}; + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + // Get chrome contexts + const result = await firefox.sendBiDiCommand('browsingContext.getTree', { + 'moz:scope': 'chrome', + }); + + const contexts = result.contexts || []; + if (contexts.length === 0) { + throw new Error( + 'No chrome contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + ); + } + + const driver = firefox.getDriver(); + const chromeContextId = contexts[0].context; + const originalContextId = firefox.getCurrentContextId(); + + try { + // Switch to chrome context + await driver.switchTo().window(chromeContextId); + await driver.setContext('chrome'); + + // Execute chrome-privileged script to get extensions + // Use executeAsyncScript for async operations + const filterParams = { ids, name, isActive, isSystem }; + const script = ` + const callback = arguments[arguments.length - 1]; + const filter = ${JSON.stringify(filterParams)}; + (async () => { + try { + const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + let addons = await AddonManager.getAllAddons(); + + // Filter to only extensions (not themes, plugins, etc.) + addons = addons.filter(addon => addon.type === "extension"); + + // Apply filters + if (filter.ids && filter.ids.length > 0) { + addons = addons.filter(addon => filter.ids.includes(addon.id)); + } + if (filter.name) { + const search = filter.name.toLowerCase(); + addons = addons.filter(addon => addon.name.toLowerCase().includes(search)); + } + if (typeof filter.isActive === 'boolean') { + addons = addons.filter(addon => addon.isActive === filter.isActive); + } + if (typeof filter.isSystem === 'boolean') { + addons = addons.filter(addon => addon.isSystem === filter.isSystem); + } + + const extensions = []; + for (const addon of addons) { + const policy = WebExtensionPolicy.getByID(addon.id); + if (!policy) continue; // Skip if no policy (addon not loaded) + + extensions.push({ + id: addon.id, + name: addon.name, + version: addon.version, + isActive: addon.isActive, + isSystem: addon.isSystem, + uuid: policy.mozExtensionHostname, + baseURL: policy.baseURL, + backgroundScripts: policy.extension?.backgroundScripts || [], + manifestVersion: policy.extension?.manifest?.manifest_version || null + }); + } + + callback(extensions); + } catch (error) { + callback([]); + } + })(); + `; + + const extensions = (await driver.executeAsyncScript(script)) as ExtensionInfo[]; + + // Build filter description for output + const filterDesc = [ + ids && ids.length > 0 ? `ids: [${ids.join(', ')}]` : null, + name ? `name: "${name}"` : null, + typeof isActive === 'boolean' ? `active: ${isActive}` : null, + typeof isSystem === 'boolean' ? `system: ${isSystem}` : null, + ] + .filter(Boolean) + .join(', '); + + return successResponse(formatExtensionList(extensions, filterDesc || undefined)); + } finally { + // Restore content context + try { + await driver.setContext('content'); + if (originalContextId) { + await driver.switchTo().window(originalContextId); + } + } catch { + // Ignore errors restoring context + } + } + } catch (error) { + if (error instanceof Error && error.message.includes('UnsupportedOperationError')) { + return errorResponse( + new Error( + 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' + ) + ); + } + return errorResponse(error as Error); + } +} From e09c55029956a9e24761f2993778038fe17a1c49 Mon Sep 17 00:00:00 2001 From: Ruhollah Majdoddin Date: Fri, 6 Feb 2026 12:09:11 +0100 Subject: [PATCH 15/20] docs: Add webExtension tools to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f348ce4..53c1535 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ BiDi-dependent features (console events, network events) are not available in co - Screenshot: page/by uid (with optional `saveTo` for CLI environments) - Script: evaluate_script (content), evaluate_chrome_script (privileged) - Chrome Context: list/select chrome contexts (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) +- WebExtension: install_extension, uninstall_extension, list_extensions (list requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) - Firefox Management: get_firefox_info, get_firefox_output, restart_firefox, set_firefox_prefs, get_firefox_prefs - Utilities: accept/dismiss dialog, history back/forward, set viewport From 506e05a703c30a02e26516449aaf553e2ed093d4 Mon Sep 17 00:00:00 2001 From: Paul Adenot Date: Thu, 26 Feb 2026 18:25:17 +0100 Subject: [PATCH 16/20] Update src/tools/webextension.ts Co-authored-by: Julian Descottes --- src/tools/webextension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/webextension.ts b/src/tools/webextension.ts index 8479271..24bb33f 100644 --- a/src/tools/webextension.ts +++ b/src/tools/webextension.ts @@ -88,7 +88,7 @@ export async function handleInstallExtension(args: unknown): Promise Date: Fri, 27 Feb 2026 16:57:20 +0100 Subject: [PATCH 17/20] fix: do not kill unrelated firefox child processes on test cleanup --- tests/setup.ts | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/setup.ts b/tests/setup.ts index 1ecccea..c89651f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -27,18 +27,30 @@ function cleanup() { isCleaningUp = true; try { - // Kill all Firefox processes started with --marionette flag (test instances) - execSync('pkill -9 -f "firefox.*marionette" 2>/dev/null || true', { - stdio: 'ignore', - }); + // Find Firefox processes started with --marionette (test instances) + const firefoxPids = execSync('pgrep -f "firefox.*marionette" || true', { + encoding: 'utf-8', + }) + .trim() + .split('\n') + .filter(Boolean); - // Kill all geckodriver processes - execSync('pkill -9 -f geckodriver 2>/dev/null || true', { - stdio: 'ignore', - }); + // Kill children of each Firefox test process, then kill the parent + for (const pid of firefoxPids) { + try { + execSync(`pkill -9 -P ${pid} 2>/dev/null || true`, { stdio: 'ignore' }); + } catch { + // Ignore errors - child processes might already be dead + } + try { + execSync(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'ignore' }); + } catch { + // Ignore errors - process might already be dead + } + } - // Kill plugin containers (child processes of Firefox) - execSync('pkill -9 -f "plugin-container" 2>/dev/null || true', { + // Kill all geckodriver processes + execSync('pkill -9 -f geckodriver || true', { stdio: 'ignore', }); From 73fa38a8a4f68cf6efb94a9b8328e2899a29bf5a Mon Sep 17 00:00:00 2001 From: Julian Descottes Date: Fri, 27 Mar 2026 11:48:21 +0100 Subject: [PATCH 18/20] Post rebase fixes and cleanups Fixing some inconsistencies after rebasing the mozilla fork upstream as well as several cleanups: - stop referring to chrome context and prefer privileged context - remove md files which are not in english - update some urls from freema to mozilla --- CHANGELOG.md | 49 +- README.md | 18 +- docs/ci-and-release.md | 2 +- docs/future-features.md | 148 ---- package.json | 12 +- .../.claude-plugin/plugin.json | 2 +- plugins/claude/firefox-devtools/README.md | 2 +- scripts/run-integration-tests-windows.mjs | 4 +- src/firefox/core.ts | 22 +- src/index.ts | 16 +- src/tools/firefox-prefs.ts | 12 +- src/tools/index.ts | 16 +- ...hrome-context.ts => privileged-context.ts} | 40 +- src/tools/webextension.ts | 29 +- tasks/99-specification.md | 660 ------------------ tasks/README.md | 113 --- test-mozlog.js | 21 +- ...e-context.js => test-privileged-context.js | 45 +- tests/firefox/core-prefs.test.ts | 18 +- tests/tools/firefox-prefs.test.ts | 20 +- 20 files changed, 178 insertions(+), 1071 deletions(-) delete mode 100644 docs/future-features.md rename src/tools/{chrome-context.ts => privileged-context.ts} (65%) delete mode 100644 tasks/99-specification.md delete mode 100644 tasks/README.md rename test-chrome-context.js => test-privileged-context.js (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc727c3..c8d94ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Merged features from Mozilla's temporary fork (https://github.com/padenot/firefox-devtools-mcp): + - Add tool to evaluate JS against the content page + - Add tool to install, uninstall and list webextensions + - Add tool to restart Firefox + - Add tool to read and write preferences + - Improved support for reusing existing profile folder + - Support for MOZ_LOG + - Support privileged context + - Support for sending WebDriver BiDi commands + +## [0.8.1] - 2026-03-17 + +### Fixed +- Increase snapshot test timeout + +## [0.8.0] - 2026-03-17 + +### Added +- Support --connect-existing to attach to running Firefox + ## [0.7.1] - 2026-02-13 ### Fixed @@ -32,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Enhanced Vue/Livewire/Alpine.js support**: New snapshot options for modern JavaScript frameworks - `includeAll` parameter: Include all visible elements without relevance filtering - `selector` parameter: Scope snapshot to specific DOM subtree using CSS selector - - Fixes [#36](https://github.com/freema/firefox-devtools-mcp/issues/36) - DOM filtering problem with Vue and Livewire applications + - Fixes [#36](https://github.com/mozilla/firefox-devtools-mcp/issues/36) - DOM filtering problem with Vue and Livewire applications - **Test fixtures**: Added new HTML fixtures for testing visibility edge cases (`visibility.html`, `selector.html`) ### Changed @@ -59,7 +80,7 @@ Released on npm, see GitHub releases for details. ### Added - Windows-specific integration test runner (`scripts/run-integration-tests-windows.mjs`) - Runs integration tests directly via Node.js to avoid vitest fork issues on Windows - - See [#33](https://github.com/freema/firefox-devtools-mcp/issues/33) for details + - See [#33](https://github.com/mozilla/firefox-devtools-mcp/issues/33) for details - Documentation for Windows integration tests in `docs/ci-and-release.md` - Branch protection enabled on `main` branch @@ -184,15 +205,15 @@ Released on npm, see GitHub releases for details. - UID-based element referencing system - Headless mode support -[0.7.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.7.0...v0.7.1 -[0.7.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.6.1...v0.7.0 -[0.6.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.6.0...v0.6.1 -[0.5.3]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.2...v0.5.3 -[0.5.2]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.1...v0.5.2 -[0.5.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.0...v0.5.1 -[0.5.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.4.0...v0.5.0 -[0.4.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.3.0...v0.4.0 -[0.3.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.2.5...v0.3.0 -[0.2.5]: https://github.com/freema/firefox-devtools-mcp/compare/v0.2.3...v0.2.5 -[0.2.3]: https://github.com/freema/firefox-devtools-mcp/compare/v0.2.0...v0.2.3 -[0.2.0]: https://github.com/freema/firefox-devtools-mcp/releases/tag/v0.2.0 +[0.7.1]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.7.0...v0.7.1 +[0.7.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.6.1...v0.7.0 +[0.6.1]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.6.0...v0.6.1 +[0.5.3]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.5.2...v0.5.3 +[0.5.2]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.5.1...v0.5.2 +[0.5.1]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.2.5...v0.3.0 +[0.2.5]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.2.3...v0.2.5 +[0.2.3]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.2.0...v0.2.3 +[0.2.0]: https://github.com/mozilla/firefox-devtools-mcp/releases/tag/v0.2.0 diff --git a/README.md b/README.md index 53c1535..62467fd 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Firefox DevTools MCP [![npm version](https://badge.fury.io/js/firefox-devtools-mcp.svg)](https://www.npmjs.com/package/firefox-devtools-mcp) -[![CI](https://github.com/freema/firefox-devtools-mcp/workflows/CI/badge.svg)](https://github.com/freema/firefox-devtools-mcp/actions/workflows/ci.yml) +[![CI](https://github.com/mozilla/firefox-devtools-mcp/workflows/CI/badge.svg)](https://github.com/mozilla/firefox-devtools-mcp/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/freema/firefox-devtools-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/freema/firefox-devtools-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Glama +Glama Model Context Protocol server for automating Firefox via WebDriver BiDi (through Selenium WebDriver). Works with Claude Code, Claude Desktop, Cursor, Cline and other MCP clients. -Repository: https://github.com/freema/firefox-devtools-mcp +Repository: https://github.com/mozilla/firefox-devtools-mcp > **Note**: This MCP server requires a local Firefox browser installation and cannot run on cloud hosting services like glama.ai. Use `npx firefox-devtools-mcp@latest` to run locally, or use Docker with the provided Dockerfile. @@ -97,6 +97,8 @@ You can pass flags or environment variables (names on the right): - `--marionette-port` — Marionette port for connect-existing mode, default 2828 (`MARIONETTE_PORT`) - `--pref name=value` — set Firefox preference at startup (repeatable, requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) +> **Note on `--pref`:** When Firefox runs in automation, it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that modify browser behavior for testing. The `--pref` option allows overriding these defaults when needed. + ### Connect to existing Firefox Use `--connect-existing` to automate your real browsing session — with cookies, logins, and open tabs intact: @@ -119,10 +121,6 @@ BiDi-dependent features (console events, network events) are not available in co > Only enable Marionette when you need MCP automation, then restart Firefox > normally afterward. -> **Note on `--pref`:** When Firefox runs in WebDriver BiDi mode, it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that modify browser behavior for testing. The `--pref` option allows overriding these defaults when needed (e.g., for Firefox development, debugging, or testing scenarios that require production-like behavior). -> -> **Example:** `--pref "browser.ml.enable=true"` enables Firefox's ML/AI features. This is essential when using this MCP server to develop or test AI-powered features like Smart Window, since RecommendedPreferences disables it by default. - ## Tool overview - Pages: list/new/navigate/select/close @@ -131,8 +129,8 @@ BiDi-dependent features (console events, network events) are not available in co - Network: list/get (ID‑first, filters, always‑on capture) - Console: list/clear - Screenshot: page/by uid (with optional `saveTo` for CLI environments) -- Script: evaluate_script (content), evaluate_chrome_script (privileged) -- Chrome Context: list/select chrome contexts (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) +- Script: evaluate_script +- Privileged Context: list/select privileged ("chrome") contexts, evaluate_privileged_script (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) - WebExtension: install_extension, uninstall_extension, list_extensions (list requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) - Firefox Management: get_firefox_info, get_firefox_output, restart_firefox, set_firefox_prefs, get_firefox_prefs - Utilities: accept/dismiss dialog, history back/forward, set viewport @@ -204,4 +202,4 @@ npm run inspector:dev ## Author -Created by [TomĂĄĆĄ Grasl](https://www.tomasgrasl.cz/) +Created by [TomĂĄĆĄ Grasl](https://www.tomasgrasl.cz/), maintained by [Mozilla](https://www.mozilla.org). diff --git a/docs/ci-and-release.md b/docs/ci-and-release.md index 6d6c018..f81dd56 100644 --- a/docs/ci-and-release.md +++ b/docs/ci-and-release.md @@ -39,7 +39,7 @@ Release flow Windows Integration Tests - On Windows, vitest has known issues with process forking when running integration tests that spawn Firefox. -- See: https://github.com/freema/firefox-devtools-mcp/issues/33 +- See: https://github.com/mozilla/firefox-devtools-mcp/issues/33 - To work around this, we use a separate test runner (`scripts/run-integration-tests-windows.mjs`) that runs integration tests directly via Node.js without vitest's process isolation. - The CI workflow detects Windows and automatically uses this runner instead of vitest for integration tests. - Unit tests still run via vitest on all platforms. diff --git a/docs/future-features.md b/docs/future-features.md deleted file mode 100644 index 35c8257..0000000 --- a/docs/future-features.md +++ /dev/null @@ -1,148 +0,0 @@ -# Future Features - -This document tracks features that are planned or considered for future implementation but are currently disabled or not yet implemented. - -## Disabled Features - -### `evaluate_script` Tool - -**Status:** Temporarily Disabled -**Reason:** Needs further consideration for security and use case validation -**Implementation:** Fully implemented in `src/tools/script.ts` - -#### Description - -The `evaluate_script` tool allows executing arbitrary JavaScript functions inside the currently selected browser page. It supports: - -- Synchronous and async functions -- Passing arguments via UIDs from snapshots -- Timeout protection against infinite loops -- JSON-serializable return values - -#### Example Usage - -```json -{ - "function": "() => document.title", - "timeout": 5000 -} -``` - -```json -{ - "function": "(el) => el.innerText", - "args": [{ "uid": "abc123" }] -} -``` - -#### Security Considerations - -This tool allows executing arbitrary JavaScript in the browser context, which requires careful consideration: - -1. **Sandboxing:** Scripts run in the page context with full DOM access -2. **Timeout Protection:** Default 5s timeout prevents infinite loops -3. **Size Limits:** Functions limited to 16KB -4. **Return Values:** Must be JSON-serializable - -#### Future Work - -Before re-enabling this tool, consider: - -- [ ] Add explicit security warnings in tool description -- [ ] Document safe usage patterns and anti-patterns -- [ ] Consider adding a "safe mode" with restricted APIs -- [ ] Add example use cases to documentation -- [ ] Evaluate if snapshot + UID-based tools cover most use cases - -#### Re-enabling - -To re-enable this tool: - -1. Uncomment exports in `src/tools/index.ts` -2. Uncomment handler registration in `src/index.ts` -3. Uncomment tool definition in `src/index.ts` allTools array -4. Update documentation with security guidelines -5. Run tests: `npm test -- script` - ---- - -## Planned Features - -### BiDi Native Tab Management - -**Status:** Planned -**Priority:** High - -Currently, tab management uses Selenium WebDriver's window handles. Future versions should use Firefox BiDi's native `browsingContext` API for: - -- Better performance -- More reliable tab metadata -- Real-time tab updates -- Window management - -**Implementation Location:** `src/firefox/pages.ts` - -### Console Message Filtering by Source - -**Status:** Planned -**Priority:** Medium - -Add ability to filter console messages by source (realm/context): - -```json -{ - "level": "error", - "source": "worker", - "limit": 10 -} -``` - -**Implementation Location:** `src/tools/console.ts` - -### Network Request Body Capture - -**Status:** Planned -**Priority:** Medium - -Capture and expose request/response bodies for network requests: - -- POST/PUT request bodies -- Response bodies (with size limits) -- Binary data handling - -**Implementation Location:** `src/firefox/events/network.ts` - -### Advanced Performance Profiling - -**Status:** Planned -**Priority:** Low -**Note:** Basic performance metrics are available via Navigation Timing API (not exposed as MCP tools) - -Full performance profiling support would include: - -- CPU profiling -- Memory snapshots -- FPS monitoring -- Long task detection -- Custom performance marks and measures - -**Reason for deferral:** WebDriver BiDi does not currently provide advanced profiling APIs. Use Firefox DevTools UI Performance panel for advanced profiling. - -**Previous implementation:** Performance tools were removed in PERFORMANCE-01 task due to limited BiDi support and complexity for minimal value. - ---- - -## Rejected Features - -None yet. - ---- - -## Contributing - -Have an idea for a new feature? Please: - -1. Check this document first -2. Open an issue with the `feature-request` label -3. Describe the use case and benefits -4. Consider security and performance implications diff --git a/package.json b/package.json index 546f879..c881d1c 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "@padenot/firefox-devtools-mcp", + "name": "firefox-devtools-mcp", "version": "0.8.1", - "description": "Model Context Protocol (MCP) server for Firefox DevTools automation (fork with Firefox management tools)", - "author": "padenot", + "description": "Model Context Protocol (MCP) server for Firefox DevTools automation", + "author": "freema", "license": "MIT", "type": "module", "main": "dist/index.js", @@ -95,12 +95,12 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/padenot/firefox-devtools-mcp.git" + "url": "git+https://github.com/mozilla/firefox-devtools-mcp.git" }, "bugs": { - "url": "https://github.com/padenot/firefox-devtools-mcp/issues" + "url": "https://github.com/mozilla/firefox-devtools-mcp/issues" }, - "homepage": "https://github.com/padenot/firefox-devtools-mcp#readme", + "homepage": "https://github.com/mozilla/firefox-devtools-mcp#readme", "publishConfig": { "access": "public" } diff --git a/plugins/claude/firefox-devtools/.claude-plugin/plugin.json b/plugins/claude/firefox-devtools/.claude-plugin/plugin.json index fc91bdb..e9b838d 100644 --- a/plugins/claude/firefox-devtools/.claude-plugin/plugin.json +++ b/plugins/claude/firefox-devtools/.claude-plugin/plugin.json @@ -6,6 +6,6 @@ "name": "TomĂĄĆĄ Grasl", "url": "https://www.tomasgrasl.cz/" }, - "repository": "https://github.com/freema/firefox-devtools-mcp", + "repository": "https://github.com/mozilla/firefox-devtools-mcp", "license": "MIT" } diff --git a/plugins/claude/firefox-devtools/README.md b/plugins/claude/firefox-devtools/README.md index 5b89a6b..7919c50 100644 --- a/plugins/claude/firefox-devtools/README.md +++ b/plugins/claude/firefox-devtools/README.md @@ -78,5 +78,5 @@ The plugin works automatically when you ask about browser tasks: ## Links -- [Repository](https://github.com/freema/firefox-devtools-mcp) +- [Repository](https://github.com/mozilla/firefox-devtools-mcp) - [npm](https://www.npmjs.com/package/firefox-devtools-mcp) diff --git a/scripts/run-integration-tests-windows.mjs b/scripts/run-integration-tests-windows.mjs index 96448ff..41583e5 100644 --- a/scripts/run-integration-tests-windows.mjs +++ b/scripts/run-integration-tests-windows.mjs @@ -3,7 +3,7 @@ * Windows Integration Tests Runner * * Runs integration tests directly via node to avoid vitest fork issues on Windows. - * See: https://github.com/freema/firefox-devtools-mcp/issues/33 + * See: https://github.com/mozilla/firefox-devtools-mcp/issues/33 */ import { FirefoxDevTools } from '../dist/index.js'; @@ -94,7 +94,7 @@ async function snapshotTests() { await test('should take snapshot', async () => { const fixturePath = `file://${fixturesPath}/simple.html`; await firefox.navigate(fixturePath); - await new Promise(r => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 500)); const snapshot = await firefox.takeSnapshot(); assert(snapshot, 'snapshot should exist'); diff --git a/src/firefox/core.ts b/src/firefox/core.ts index 2d58b98..2e711ac 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -26,6 +26,17 @@ export interface IElement { 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; +} + /* eslint-disable @typescript-eslint/no-explicit-any */ export interface IDriver { getTitle(): Promise; @@ -66,6 +77,7 @@ export interface IDriver { perform(): Promise; clear(): Promise; }; + getBidi(): Promise; } /* eslint-enable @typescript-eslint/no-explicit-any */ @@ -423,6 +435,10 @@ class GeckodriverHttpDriver implements IDriver { kill(): void { this.gdProcess.kill(); } + + getBidi(): Promise { + throw new Error('BiDi not available in connect-existing mode'); + } } // --------------------------------------------------------------------------- @@ -684,7 +700,7 @@ export class FirefoxCore { throw new Error('Driver not connected'); } - // Get chrome contexts + // Get privileged ("chrome") contexts const result = await this.sendBiDiCommand('browsingContext.getTree', { 'moz:scope': 'chrome', }); @@ -692,7 +708,7 @@ export class FirefoxCore { const contexts = result.contexts || []; if (contexts.length === 0) { throw new Error( - 'No chrome contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' + 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' ); } @@ -782,7 +798,7 @@ export class FirefoxCore { } const bidi = await this.driver.getBidi(); - const ws: any = bidi.socket; + const ws = bidi.socket; // Wait for WebSocket to be ready before sending await this.waitForWebSocketOpen(ws); diff --git a/src/index.ts b/src/index.ts index 685e107..140690f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,10 +208,10 @@ const toolHandlers = new Map Promise { +export async function handleListPrivilegedContexts(_args: unknown): Promise { try { const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); @@ -79,7 +79,7 @@ export async function handleListChromeContexts(_args: unknown): Promise { +export async function handleSelectPrivilegedContext(args: unknown): Promise { try { const { contextId } = args as { contextId: string }; @@ -106,20 +106,20 @@ export async function handleSelectChromeContext(args: unknown): Promise { +export async function handleEvaluatePrivilegedScript(args: unknown): Promise { try { const { expression } = args as { expression: string }; diff --git a/src/tools/webextension.ts b/src/tools/webextension.ts index 24bb33f..168ee0d 100644 --- a/src/tools/webextension.ts +++ b/src/tools/webextension.ts @@ -145,7 +145,10 @@ export async function handleUninstallExtension(args: unknown): Promise { try { - const { ids, name, isActive, isSystem } = (args as { - ids?: string[]; - name?: string; - isActive?: boolean; - isSystem?: boolean; - }) || {}; + const { ids, name, isActive, isSystem } = + (args as { + ids?: string[]; + name?: string; + isActive?: boolean; + isSystem?: boolean; + }) || {}; const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); - // Get chrome contexts + // Get privileged ("chrome") contexts const result = await firefox.sendBiDiCommand('browsingContext.getTree', { 'moz:scope': 'chrome', }); @@ -237,7 +240,7 @@ export async function handleListExtensions(args: unknown): Promise -- Options: - - `firefoxPath?: string` - Cesta k Firefox binary - - `headless: boolean` - Headless mode - - `profilePath?: string` - Custom Firefox profile - - `viewport?: {width, height}` - Velikost okna - - `args?: string[]` - DalĆĄĂ­ Firefox argumenty - - `startUrl?: string` - PočátečnĂ­ URL - -**`FirefoxClient.close()`** -- Ukončí Firefox instanci -- Cleanup vĆĄech resources -- VracĂ­: Promise - -### 2. Navigace a SprĂĄva StrĂĄnek ✅ - -**`navigate(url: string)`** -- Naviguje na URL -- Automaticky čistĂ­ console a snapshot cache -- VracĂ­: Promise - -**`navigateBack()` / `navigateForward()`** -- Historie navigace -- VracĂ­: Promise - -**`getTabs()` / `selectTab(index)` / `createNewPage(url)` / `closeTab(index)`** -- Tab management pƙes window handles -- VracĂ­: tab info nebo Promise - -**`refreshTabs()` / `getSelectedTabIdx()`** -- Tab metadata operations - -### 3. Viewport & Dialogs ✅ - -**`setViewportSize(width: number, height: number)`** -- ZměnĂ­ velikost viewport -- VracĂ­: Promise - -**`acceptDialog(promptText?: string)`** -- Pƙijme alert/confirm/prompt dialog -- Optional text input pro prompt -- VracĂ­: Promise - -**`dismissDialog()`** -- Zavƙe/zamĂ­tne dialog -- VracĂ­: Promise - -### 4. JavaScript Execution ✅ - -**`evaluate(script: string)`** -- VykonĂĄ JavaScript v page context -- AutomatickĂ© `return` wrapping -- VracĂ­: Promise (JSON-serializable result) - -**`getContent()`** -- ZĂ­skĂĄ `document.documentElement.outerHTML` -- VracĂ­: Promise - -### 5. DOM Snapshot System ✅ - -**`takeSnapshot()`** -- KompletnĂ­ DOM snapshot s UIDs -- VracĂ­: `Promise` - - `json: SnapshotJson` - StrukturovanĂœ DOM tree - - `text: string` - LLM-optimized textovĂĄ reprezentace - -**SnapshotNode structure:** -```typescript -{ - uid: string, // UnikĂĄtnĂ­ ID (snapshotId_nodeId) - tag: string, // HTML tag name - role?: string, // ARIA role nebo semantickĂĄ role - name?: string, // Accessible name - value?: string, // Input/textarea value - href?: string, // Link href - src?: string, // Image/iframe src - text?: string, // Text content - aria?: AriaAttributes, // ARIA properties - computed?: { // Computed properties - focusable?: boolean, - interactive?: boolean, - visible?: boolean - }, - children: SnapshotNode[] // Nested elements -} -``` - -**`resolveUidToSelector(uid: string)`** -- Pƙevede UID na CSS selector -- Validuje staleness (snapshot ID) -- VracĂ­: string - -**`resolveUidToElement(uid: string)`** -- Pƙevede UID na WebElement -- Caching s staleness detection -- Fallback na XPath pƙi selhĂĄnĂ­ CSS -- VracĂ­: Promise - -**`clearSnapshot()`** -- VyčistĂ­ snapshot cache - -### 6. User Interaction (Selector-based) ✅ - -**`clickBySelector(selector: string)`** -- Klikne na element -- VracĂ­: Promise - -**`hoverBySelector(selector: string)`** -- Hover nad element -- VracĂ­: Promise - -**`fillBySelector(selector: string, text: string)`** -- VyplnĂ­ input/textarea -- Clear + sendKeys -- VracĂ­: Promise - -**`dragAndDropBySelectors(source: string, target: string)`** -- Drag & drop mezi elementy -- JS fallback (HTML5 DnD API) -- VracĂ­: Promise - -**`uploadFileBySelector(selector: string, filePath: string)`** -- Upload souboru -- JS unhide + sendKeys -- VracĂ­: Promise - -### 7. User Interaction (UID-based) ✅ - -**`clickByUid(uid: string, dblClick = false)`** -- Klikne na element podle UID -- Optional double-click -- VracĂ­: Promise - -**`hoverByUid(uid: string)`** -- Hover podle UID -- VracĂ­: Promise - -**`fillByUid(uid: string, value: string)`** -- VyplnĂ­ input podle UID -- VracĂ­: Promise - -**`dragByUidToUid(fromUid: string, toUid: string)`** -- Drag & drop mezi UIDs -- VracĂ­: Promise - -**`fillFormByUid(elements: Array<{uid, value}>)`** -- Batch form filling -- VracĂ­: Promise - -**`uploadFileByUid(uid: string, filePath: string)`** -- Upload podle UID -- VracĂ­: Promise - -### 8. Screenshots ✅ - -**`takeScreenshotPage()`** -- Full page screenshot -- VracĂ­: Promise (base64 PNG) - -**`takeScreenshotByUid(uid: string)`** -- Screenshot konkrĂ©tnĂ­ho elementu -- AutomatickĂœ scrollIntoView -- Element cropping (native Selenium) -- VracĂ­: Promise (base64 PNG) - -### 9. Console Monitoring ✅ - -**`getConsoleMessages()`** -- ZĂ­skĂĄ vĆĄechny console logy -- VracĂ­: Promise - - `level: 'debug' | 'info' | 'warn' | 'error'` - - `text: string` - - `timestamp: number` - - `source?: string` - - `args?: unknown[]` - -**`clearConsoleMessages()`** -- VyčistĂ­ console buffer -- VracĂ­: void - -### 10. Network Monitoring ✅ - -AktuĂĄlnĂ­ pƙístup: Always‑on capture (nĂĄvrh změny) – sběr sĂ­Ć„ovĂœch udĂĄlostĂ­ bÄ›ĆŸĂ­ trvale po `connect()`, relevanci dat ƙídĂ­me pƙes nĂĄstroj `list_network_requests` (filtry `sinceMs`, `limit`, `sortBy`, 
). JednotlivĂ© requesty majĂ­ stabilnĂ­ `id` (BiDi request id), kterĂ© lze pouĆŸĂ­t v `get_network_request` pro staĆŸenĂ­ detailu. - -API (klientskĂĄ vrstva): - -**`getNetworkRequests()`** -- VrĂĄtĂ­ zachycenĂ© requesty (od poslednĂ­ho čistěnĂ­ bufferu pƙi navigaci, pokud je auto‑clear zapnut) -- VracĂ­: Promise - - `id: string` - - `url: string` - - `method: string` - - `status?: number` - - `resourceType?: string` - - `requestHeaders?: Record` - - `responseHeaders?: Record` - - `timings?: {requestTime, responseTime, duration}` - -Pozn.: DƙívějĆĄĂ­ start/stop/clear nĂĄstroje budou odstraněny z MCP vrstvy (viz tasks/NETWORK-03-...). - -## MCP Tools (BudoucĂ­ implementace) - -NĂĄsledujĂ­cĂ­ MCP tools budou vystaveny pƙes `src/index.ts` MCP server: - -PoznĂĄmka k `inputSchema`: -- VĆĄechny MCP nĂĄstroje musĂ­ pouĆŸĂ­vat čistĂ© JSON Schema (serializovatelnĂ©), ne pƙímo Zod instance. Validaci lze interně ponechat, ale schema publikovat v JSON podobě (viz tasks/SCHEMA-01-json-schema-unification.md). - -### PlĂĄnovanĂ© Tools - -1. **Browser Management** - - `firefox_launch` - SpustĂ­ Firefox (wrapper nad connect) - - `firefox_close` - Ukončí Firefox - - `firefox_get_status` - Status info - -2. **Navigation** - - `navigate_to` - Navigace na URL - - `navigate_back` / `navigate_forward` - Historie - - `list_tabs` - Seznam tabĆŻ - - `select_tab` - PƙepnutĂ­ tabu - - `create_tab` - NovĂœ tab - - `close_tab` - Zavƙít tab - -3. **DOM Inspection** - - `take_snapshot` - DOM snapshot s UIDs - - `get_page_content` - HTML content - - `find_elements` - NajĂ­t elementy (future) - - `resolve_uid` - UID → selector/element - -4. **User Interaction** - - `click_element` - Klik (selector nebo UID) - - `type_text` - PsanĂ­ textu - - `hover_element` - Hover - - `drag_and_drop` - Drag & drop - - `upload_file` - Upload souboru - - `fill_form` - Batch form filling - -5. **JavaScript** - - `evaluate_javascript` - JS eval - - `get_console_logs` - Console messages - -6. **Network & Performance** - - `list_network_requests` - Vylistovat requesty (filtry, stabilnĂ­ `id`, moĆŸnost detailnĂ­ho vĂœstupu) - - `get_network_request` - Detail poĆŸadavku podle `id` - - (odstranit) `start_network_monitor` / `stop_network_monitor` / `clear_network_requests` - - (odstranit) `get_performance_metrics`, `performance_start_trace`, `performance_stop_trace` (viz tasks/PERFORMANCE-01-...) - -7. **Screenshots** - - `take_screenshot` - Page nebo element screenshot - -8. **Dialogs & Viewport** - - `handle_dialog` - Accept/dismiss dialog - - `resize_viewport` - Změna velikosti - ---- - -## Release and Versioning (RELEASE-01) - -- Use semver in the 0.x range until the public API is stable. -- Injected snapshot bundle includes a simple version marker that is logged on load. -- Align Node.js runtime requirement with `package.json engines` (>=20). - -## Google Actions (ACTIONS-01/02) - -- Prepare Google Actions mapping for our Firefox tools. Use `old/mcp_gsheet` as inspiration only (style and structure), do not integrate Google Sheets. -- Keep action surface minimal and English‑only; inputs use plain JSON Schema. - - -9. **Storage (future)** - - `get_cookies` - ZĂ­skat cookies - - `set_cookie` - Nastavit cookie - - `get_local_storage` - LocalStorage data - - `get_session_storage` - SessionStorage data - -## TestovĂĄnĂ­ - -### ImplementovanĂ© Test Skripty - -1. **`scripts/test-bidi-devtools.js`** - - KompletnĂ­ E2E test suite (18 testĆŻ) - - Coverage vĆĄech funkcĂ­: - - Browser launch & connect - - Navigation & tabs - - Console monitoring - - Network monitoring - - JavaScript evaluation - - Snapshot system - - History navigation - - Screenshot capture - - Dialog handling - -2. **`scripts/test-input-tools.js`** - - Test vĆĄech input interakcĂ­ - - Selector-based i UID-based metody - - Click, hover, fill, drag, upload - -3. **`scripts/test-screenshot.js`** - - Full page screenshots - - Element screenshots - - Custom HTML testy - - Output do `/temp` sloĆŸky - -4. **`scripts/test-dialog.js`** - - Alert dialogs - - Confirm dialogs (accept/dismiss) - - Prompt dialogs s text inputem - - Error handling - -### NPM Test Scripts - -```bash -npm run test:tools # HlavnĂ­ E2E testy -npm run test:input # Input tools testy -npm run test:screenshot # Screenshot testy -npm run test:dialog # Dialog handling testy -``` - -### Quality Checks - -```bash -npm run check # ESLint fix + TypeScript typecheck -npm run check:all # check + vitest + build -npm run build # tsup build -``` - -## Konfigurace - -### FirefoxLaunchOptions - -```typescript -{ - firefoxPath?: string; // Auto-detect pokud nenĂ­ uvedeno - headless: boolean; // true/false - profilePath?: string; // Custom profile - viewport?: { - width: number; - height: number; - }; - args?: string[]; // Extra Firefox args - startUrl?: string; // PočátečnĂ­ URL -} -``` - -### Claude Desktop Config (MCP) - -```json -{ - "mcpServers": { - "firefox-devtools": { - "command": "node", - "args": ["/path/to/firefox-devtools-mcp/dist/index.js"], - "env": { - "FIREFOX_PATH": "/Applications/Firefox.app/Contents/MacOS/firefox" - } - } - } -} -``` - -### Environment Variables - -- `FIREFOX_PATH` - Cesta k Firefox binary (optional, auto-detect) -- `DEBUG` - Debug logging (napƙ. `DEBUG=firefox-devtools-mcp`) -- `NODE_ENV` - development/production - -## Firefox Setup - -### PoĆŸadavky - -- **Firefox:** Stable (latest), ESR, Developer Edition, nebo Nightly -- **Geckodriver:** Auto-instalovĂĄno pƙes npm (geckodriver package) -- **Node.js:** 20.19.0+ - -### BiDi Protocol - -WebDriver BiDi je automaticky aktivovĂĄn pƙes Selenium: - -```typescript -const firefoxOptions = new firefox.Options(); -firefoxOptions.enableBidi(); -``` - -**ĆœĂĄdnĂĄ manuĂĄlnĂ­ konfigurace Firefox profilu nenĂ­ potƙeba!** - -## OmezenĂ­ a ZnĂĄmĂ© Issues - -### BiDi Coverage - -✅ **Plně podporovĂĄno:** -- JavaScript evaluation -- Navigation & history -- Console monitoring -- Network monitoring (beforeRequestSent, responseStarted, responseCompleted) -- Screenshot (full page + element) -- Dialog handling -- Tab management - -⚠ **Částečně podporovĂĄno:** -- Iframe support (pouze same-origin) -- Network timing (ne tak pƙesnĂ© jako Chrome DevTools) - -❌ **NenĂ­ podporovĂĄno:** -- WebSocket monitoring (BiDi spec in progress) -- Service Worker debugging -- Cross-origin iframe inspection -- HAR export (nenĂ­ v BiDi) -- Video recording (nenĂ­ v BiDi) -- Performance profiling (pouze Performance API pƙes JS) - -### Known Issues - -1. **Data URL parsing:** Firefox mĂĄ problĂ©m s komplexnĂ­mi data: URLs - - **Fix:** PouĆŸĂ­t `about:blank` + innerHTML injection - -2. **Staleness detection:** UIDs jsou vĂĄzĂĄny na snapshot ID - - Po navigaci automaticky invalidovĂĄny - - Cache se čistĂ­ pƙi `navigate()` - -3. **Drag & Drop:** Native WebDriver DnD je nestabilnĂ­ - - **Fix:** JS fallback s HTML5 DnD API - -4. **File Upload:** Input mĆŻĆŸe bĂœt `display: none` - - **Fix:** JS unhide pƙed sendKeys - -## Performance & Optimalizace - -### ImplementovanĂ© optimalizace - -1. **Element caching** - UID → WebElement cache -2. **Staleness detection** - Snapshot ID validation -3. **Lazy event subscription** - BiDi events pouze pƙi connect -4. **Always‑on network capture** - Filtry (`sinceMs`, `limit`) mĂ­sto start/stop -5. **Efficient selectors** - CSS primary, XPath fallback - -### Resource Cleanup - -- AutomatickĂ© cleanup pƙi `close()` -- Console/Network buffer clearing -- Snapshot cache invalidation na navigation - -## Development - -### Struktura projektu - -``` -firefox-devtools-mcp/ -├── src/ -│ ├── index.ts # MCP server entry point -│ ├── firefox/ # Firefox client library -│ ├── tools/ # MCP tool definitions (future) -│ └── utils/ # Shared utilities -├── scripts/ # Test & setup scripts -├── tasks/ # Task specifications -├── old/ # Reference implementations -├── temp/ # Test artifacts -└── dist/ # Build output -``` - -### Build System - -- **Builder:** tsup (esbuild wrapper) -- **Target:** Node 20 ESM -- **Output:** Single-file bundle + type definitions -- **Watch mode:** `npm run dev` - -### Code Quality - -- **Linter:** ESLint + TypeScript plugin -- **Formatter:** Prettier -- **Types:** Strict TypeScript (`exactOptionalPropertyTypes: true`) -- **Testing:** Vitest + manual E2E scripts - - **Comment Style:** English only; concise, accurate, and durable (no internal task numbers). User‑facing caveats belong in docs, not tool descriptions. See tasks/CODE-COMMENTS-01-review-and-cleanup.md. - -## Roadmap - -### ✅ Completed (Q1 2025) - -- [x] Project scaffold & TypeScript setup -- [x] BiDi connection & WebDriver integration -- [x] Modular architecture (core, events, dom, pages, snapshot) -- [x] Console & Network monitoring -- [x] Snapshot system s UID mapping -- [x] Selector-based input tools -- [x] UID-based input tools -- [x] Screenshot tools (page + element) -- [x] Dialog handling -- [x] Comprehensive test coverage - -### 🚧 In Progress (Q2 2025) - -- [ ] MCP Tools implementation (`src/tools/`) -- [ ] MCP Server integration (`src/index.ts`) -- [ ] Resource & Prompt definitions -- [ ] Error handling standardization -- [ ] Tool documentation & examples - -### 📋 Future Features - -#### Short-term -- [ ] Cookie management -- [ ] LocalStorage/SessionStorage access -- [ ] Element visibility checks -- [ ] Wait conditions (element present, visible, etc.) -- [ ] Keyboard shortcuts simulation -- [ ] Mouse wheel scroll - - [ ] Overhaul sĂ­Ć„ovĂœch nĂĄstrojĆŻ (NETWORK-01/02/03) - - [ ] SjednocenĂ­ `inputSchema` na čistĂ© JSON Schema (SCHEMA-01) - - [ ] OdstraněnĂ­ performance nĂĄstrojĆŻ z MCP (PERFORMANCE-01) - - [ ] VylepĆĄit `take_snapshot` (SNAPSHOT-01) - -#### Medium-term -- [ ] Performance metrics (Performance API wrapper) -- [ ] Advanced selector strategies (text content, label) -- [ ] Accessibility tree snapshot -- [ ] Cross-origin iframe support (if BiDi adds) -- [ ] WebSocket monitoring (when BiDi supports) - -#### Long-term -- [ ] Multi-profile support -- [ ] Remote Firefox connection -- [ ] HAR export (custom implementation) -- [ ] Screenshot comparison -- [ ] Video recording (screencast) -- [ ] Firefox Developer Edition specifics -- [ ] WebExtension debugging support - -## Kompatibilita - -### Firefox Verze -- ✅ Firefox Stable (latest) - Primary target -- ✅ Firefox ESR - Supported -- ✅ Firefox Developer Edition - Supported -- ✅ Firefox Nightly - Supported (ale mĆŻĆŸe mĂ­t BiDi breaking changes) - -### OS Support -- ✅ macOS (tested: macOS Sequoia 15.6) -- ✅ Linux (via Selenium WebDriver) -- ✅ Windows (via Selenium WebDriver) - -### Node.js -- ✅ Node 20.19.0+ (required) -- ❌ Node 18.x (nenĂ­ testovĂĄno) - -## ZĂĄvěr - -Firefox DevTools MCP je kompletnĂ­ automation library postavenĂĄ na modernĂ­m WebDriver BiDi protokolu. Poskytuje: - -- **Čistou TypeScript API** s type safety -- **Modular architecture** s jasnou separation of concerns -- **UID-based interaction** pro AI-friendly DOM targeting -- **Comprehensive testing** s E2E coverage -- **Production-ready** s error handling a resource cleanup - -**Ready for MCP integration!** DalĆĄĂ­ krok je implementace MCP Tools vrstvy a pƙipojenĂ­ na MCP SDK. diff --git a/tasks/README.md b/tasks/README.md deleted file mode 100644 index 9cc2c26..0000000 --- a/tasks/README.md +++ /dev/null @@ -1,113 +0,0 @@ -**Firefox DevTools MCP – TODO Roadmap** - -This folder contains work items for the Firefox DevTools MCP server. Implementation follows the structure and practices used in `old/mcp_gsheet` and (where it makes sense) aims for parity with `old/mcp_dev_tool_chrome`. - -- KompletnĂ­ specifikace (pĆŻvodnĂ­ nĂĄvrh) byla pƙesunuta do: `tasks/99-specification.md` - - AktuĂĄlnĂ­ analĂœza MCP nĂĄstrojĆŻ a nĂĄvrhy zjednoduĆĄenĂ­: `tasks/tools-analysis.md` - -Stav prĂĄce budeme ƙídit pƙes checklist nĂ­ĆŸe. KaĆŸdĂœ bod odkazuje na samostatnĂœ Ășkol s detaily, akceptačnĂ­mi kritĂ©rii, referencemi a ukĂĄzkovĂœmi snippetami (ilustračnĂ­, ne finĂĄlnĂ­ kĂłd). - -Roadmap - -- [x] 00 – VĂœzkum a architektura: pƙístup k Firefoxu, parity s Chrome, struktura projektu (`tasks/00-architecture-research.md`) -- [x] 01 – ProjektovĂœ scaffold (TypeScript, tsup, eslint, prettier, vitest, scripts) (`tasks/01-project-scaffold.md`) -- [x] 02 – Struktura `src/` a skelet MCP serveru (`tasks/02-structure-and-boilerplate.md`) -- [x] 03 – Konfigurace, `.env`, setup skript pro MCP klienty, Inspector (`tasks/03-config-env-and-scripts.md`) -- [x] 04 – Taskfile.yaml a Dockerfile pro lokĂĄlnĂ­ testovĂĄnĂ­ a Inspector (`tasks/04-taskfile-and-dockerfile.md`) -- [x] 05 – Vrstva prohlĂ­ĆŸeče (McpContext/browser wrapper) (`tasks/05-browser-abstraction.md`) -- [x] 06 – Tools: Navigace a sprĂĄva strĂĄnek (MVP) (`tasks/06-tools-pages-and-navigation.md`) -- [x] 07 – Tools: Debug a screenshoty (MVP) (`tasks/07-tools-debug-and-screenshot.md`) -- [x] 08 – Tools: Console a evaluate (MVP) (`tasks/08-tools-console-and-script.md`) -- [x] 09 – Tools: SĂ­Ć„ a vĂœkon (iterace, omezenĂ­ Firefoxu) (`tasks/09-tools-network-and-performance.md`) -- [x] 10 – TestovĂĄnĂ­ (unit/integration) a Inspector workflow (`tasks/10-testing-and-inspector.md`) -- [x] 11 – BalíčkovĂĄnĂ­, metadata, `server.json`, publikace (`tasks/11-ci-and-packaging.md`) -- [x] 12 – Dokumentace: README, Tool reference, Troubleshooting (`tasks/12-docs-and-readme.md`) - - [x] 13 – Launcher: RDP pƙepĂ­nače a readiness (`tasks/13-launcher-rdp-flags-and-readiness.md`) - - [x] 14 – Launcher: Detekce binĂĄrky + edice (`tasks/14-launcher-executable-detection-and-editions.md`) - - [x] 15 – BiDi port a screenshot (volitelnĂ©) (`tasks/15-bidi-port-and-screenshot.md`) -- [x] 16 – Docs: VlastnĂ­ Firefox klient (EN) (`tasks/16-docs-firefox-client.md`) -- [x] 17 – BiDi coverage vs. Chrome tools (`tasks/17-bidi-coverage-vs-chrome-tools.md`) -- [x] 18 – Refaktor architektury `src/firefox/` (modularizace) (`tasks/18-firefox-client-architecture-refactor.md`) -- [x] 19 – Network backend (BiDi events) (`tasks/19-network-backend-bidi-events.md`) -- [x] 20 – Snapshot + UID mapping (`tasks/20-snapshot-and-uid-mapping.md`) -- [x] 21 – Input tools (click/hover/fill/drag/upload) (`tasks/21-input-tools-bidi.md`) -- [x] 22 – Screenshot tool (page/element) (`tasks/22-screenshot-tool.md`) -- [x] 23 – Page utilities (history/resize/dialog) (`tasks/23-page-utilities-history-resize-dialog.md`) -- [x] 24 – Remove legacy RDP options & wording (`tasks/24-remove-legacy-rdp.md`) -- [x] 25 – Snapshot: Finalization and Extensions (`tasks/25-snapshot-finalization-and-extensions.md`) -- [x] 26 – Firefox modules cleanup & lifecycle hooks (`tasks/26-firefox-modules-cleanup-and-lifecycle.md`) -- [x] 27 – Offline test harness & scripts refactor (`tasks/27-offline-test-harness-and-scripts-refactor.md`) -- [x] 28 – Snapshot bundling integration & de‑dup (`tasks/28-snapshot-bundling-integration-and-dedup.md`) - - [x] 29 – MCP tools: Snapshot integration (`tasks/29-mcp-tools-snapshot-integration.md`) - - [x] 30 – MCP tools: Input akce podle UID (`tasks/30-mcp-tools-input-uid-actions.md`) - - [x] 31 – MCP tools: Screenshot a Utility akce strĂĄnky (`tasks/31-mcp-tools-screenshot-and-page-utilities.md`) - - [x] 32 – MCP tools: Refaktor evaluate_script (`tasks/32-mcp-tools-evaluate-refactor.md`) - - [x] 33 – MCP tools: SĂ­Ć„ovĂ© nĂĄstroje – filtry + čiĆĄtěnĂ­ (`tasks/33-mcp-tools-network-refactor.md`) -- [x] 34 – MCP tools: Console + Pages drobnĂœ refaktor (`tasks/34-mcp-tools-console-and-pages-refactor.md`) - -New priority items (overhaul) - -- [x] NETWORK-01 – Pƙepracovat `list_network_requests` (čistĂ© JSON Schema, stabilnĂ­ `id`, detailnĂ­ JSON vĂœstup) — tasks/NETWORK-01-overhaul-list_network_requests.md -- [x] NETWORK-02 – Redesign `get_network_request` (primĂĄrně podle `id`, strukturovanĂœ detail) — tasks/NETWORK-02-redesign-get_network_request.md -- [x] NETWORK-03 – Always‑on network capture; odstranit `start/stop/clear` nĂĄstroje — tasks/NETWORK-03-always-on-network-capture-and-remove-start-stop-clear.md -- [x] SCHEMA-01 – Sjednotit `inputSchema` na čistĂ© JSON Schema (zvl. network/performance) — tasks/SCHEMA-01-json-schema-unification.md -- [x] PERFORMANCE-01 – Odstranit performance nĂĄstroje (`performance_*`) z veƙejnĂ© sady — tasks/PERFORMANCE-01-remove-performance-tools.md -- [x] SNAPSHOT-01 – Vyčistit vĂœstup `take_snapshot` + doplnit nĂĄvod „co dĂĄl" + parametrizace — tasks/SNAPSHOT-01-clean-snapshot-output-and-guidance.md -- [x] PAGES – Odstranit `refresh_pages` (duplicitnĂ­ s `list_pages`) — viz tasks/tools-analysis.md -- [x] CODE-COMMENTS-01 – Review and cleanup of code comments (English only, accurate, no internal task refs) — tasks/CODE-COMMENTS-01-review-and-cleanup.md -- [x] TOOLS-PROMPTS-01 – Improve MCP tool descriptions for better agent-friendliness and consistency — tasks/TOOLS-PROMPTS-01-improvements.md - -Release 0.2.0 (2025-10-19) - -- [x] RELEASE-01 – Node.js version guard (>=20) + SERVER_VERSION 0.2.0 + bundle logging — tasks/CR-0.2.0-release-enhancements.md -- [x] SNAPSHOT-01 – Parameters (includeText, maxDepth) + clean output (no empty strings) — tasks/CR-0.2.0-release-enhancements.md -- [x] NETWORK-01/02 – Add format parameter (text/json) to network tools — tasks/CR-0.2.0-release-enhancements.md -- [x] CONSOLE-01 – Add filters (textContains, source) + format parameter — tasks/CR-0.2.0-release-enhancements.md -- [x] EVENTS-01 – Memory protection (TTL 5min + max 1000 items) for console/network buffers — tasks/CR-0.2.0-release-enhancements.md -- [x] ACTIONS-01 – GitHub Actions CI/CD outline — tasks/ACTIONS-01-github-actions-outline.md -- [x] ACTIONS-02 – GitHub Actions workflows implementation (ci, pr-check, publish, release) — tasks/ACTIONS-02-github-actions-implementation.md -- [x] VERSION-01 – Version synchronization (npm version lifecycle hook + version-check.yml + docs/release-process.md) — scripts/sync-version.js, .github/workflows/version-check.yml -- [x] CODECOV-01 – Codecov integration (coverage tracking + PR comments + badges) — .codecov.yml, vitest.config.ts, README.md -- [x] TESTS-01 – Smoke tests (fix CI "no test files" error) — tests/smoke.test.ts - -Upcoming work - -- [ ] TASKS-01 – Tasks folder English migration — tasks/TASKS-01-english-migration.md - -Notes - -- Code snippets reference existing implementations in `old/`. They are illustrative, not final. -- Structure, tooling, and scripts should follow `old/mcp_gsheet` where possible. -- For MCP tool naming/semantics, keep alignment with `old/mcp_dev_tool_chrome` where it improves prompt reuse. Document deviations explicitly where Firefox differs. - -Process and quality control - -- Work through tasks in sequence unless agreed otherwise. -- After each task, run `task check`. - - `task check` performs ESLint fixes and TypeScript typecheck (see Taskfile in `old/mcp_gsheet` for the pattern). -- After each task, add a code review note in `tasks/CR-.md`. - - Example: `tasks/CR-06.md` or `tasks/CR-06-tools-pages-and-navigation.md`. - - Recommended CR outline: - -```md -# Code Review – - -Date: YYYY-MM-DD - -What was done -- Short summary of changes and reasoning -- Impacted areas (files/modules) - -Decisions and impact -- Important decisions (naming, CLI behavior, defaults) -- Known limitations or tech debt - -References -- Links to `old/mcp_gsheet` and `old/mcp_dev_tool_chrome` (specific files) - -Next steps -- Follow‑up tasks/tests -``` - -- RozĆĄiƙuj `.gitignore` podle potƙeby (napƙ. `dist/`, `node_modules/`, `coverage/`, `*.log`, `.env`, dočasnĂ© soubory, artefakty Dockeru, atd.). VychĂĄzej ze vzoru v `old/mcp_gsheet/.gitignore`. -- Po kaĆŸdĂ©m dokončenĂ©m Ășkolu aktualizuj dokumentaci (README, pƙípadně `docs/*`, tool reference, poznĂĄmky k konfiguraci a omezenĂ­m). Uveď změny i do CR zĂĄznamu. diff --git a/test-mozlog.js b/test-mozlog.js index b291c40..e94c985 100755 --- a/test-mozlog.js +++ b/test-mozlog.js @@ -26,7 +26,7 @@ async function test() { await firefox.navigate('https://example.com'); console.log('✓ Navigated to example.com'); - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); try { const title = await firefox.evaluate('return document.title'); @@ -52,7 +52,7 @@ async function test() { // Navigate to another page await firefox.navigate('https://mozilla.org'); console.log('✓ Navigated to mozilla.org'); - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); try { const title2 = await firefox.evaluate('return document.title'); @@ -64,35 +64,34 @@ async function test() { // Check log file console.log('\n--- Checking MOZ_LOG output ---'); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); await firefox.close(); console.log('✓ Firefox closed'); // Give a moment for log file to be flushed - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); if (existsSync(logFile)) { try { const logContent = readFileSync(logFile, 'utf8'); - const lines = logContent.split('\n').filter(l => l.trim()); + const lines = logContent.split('\n').filter((l) => l.trim()); console.log(`✓ Log file exists with ${lines.length} lines`); // Check for HTTP logging - const httpLines = lines.filter(l => l.includes('nsHttp')); + const httpLines = lines.filter((l) => l.includes('nsHttp')); console.log(` Found ${httpLines.length} nsHttp log lines`); if (httpLines.length > 0) { console.log(' Sample HTTP log lines:'); - httpLines.slice(0, 3).forEach(line => { + httpLines.slice(0, 3).forEach((line) => { console.log(` ${line.substring(0, 100)}`); }); } // Check for timestamps - const timestampLines = lines.filter(l => /^\d{4}-\d{2}-\d{2}/.test(l)); + const timestampLines = lines.filter((l) => /^\d{4}-\d{2}-\d{2}/.test(l)); console.log(` Found ${timestampLines.length} timestamped lines`); - } catch (err) { console.log(`✗ Could not read log file: ${err.message}`); } @@ -101,12 +100,12 @@ async function test() { } console.log('\n✓ All feature tests completed!'); - console.log('\nNote: Chrome context evaluation requires:'); + console.log('\nNote: Privileged context evaluation requires:'); console.log(' - MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var'); console.log(' - Using restart_firefox tool or npm run inspector'); } -test().catch(err => { +test().catch((err) => { console.error('\nTest failed:', err); console.error(err.stack); process.exit(1); diff --git a/test-chrome-context.js b/test-privileged-context.js similarity index 59% rename from test-chrome-context.js rename to test-privileged-context.js index f497bb3..e26a014 100755 --- a/test-chrome-context.js +++ b/test-privileged-context.js @@ -3,7 +3,7 @@ import { FirefoxDevTools } from './dist/index.js'; async function test() { - console.log('=== Test: Chrome Context Script Evaluation (headless) ===\n'); + console.log('=== Test: Privileged Context Script Evaluation (headless) ===\n'); const firefox = new FirefoxDevTools({ headless: true, @@ -19,7 +19,7 @@ async function test() { // Test content script first console.log('\n--- Testing content script (default context) ---'); await firefox.navigate('https://example.com'); - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); try { const title = await firefox.evaluate('return document.title'); @@ -28,33 +28,33 @@ async function test() { console.log(`✗ Content script failed: ${err.message}`); } - // Now test chrome context via BiDi with moz:scope - console.log('\n--- Testing chrome context listing ---'); + // Now test privileged context via BiDi with moz:scope + console.log('\n--- Testing privileged context listing ---'); try { - // Use BiDi to list chrome contexts with moz:scope + // Use BiDi to list privileged contexts with moz:scope const result = await firefox.sendBiDiCommand('browsingContext.getTree', { 'moz:scope': 'chrome', }); const contexts = result.contexts || []; - console.log(`✓ Listed ${contexts.length} chrome context(s) via BiDi`); + console.log(`✓ Listed ${contexts.length} privileged context(s) via BiDi`); if (contexts.length > 0) { - console.log(' Sample chrome contexts:'); - contexts.slice(0, 3).forEach(ctx => { + console.log(' Sample privileged contexts:'); + contexts.slice(0, 3).forEach((ctx) => { console.log(` ${ctx.context}: ${ctx.url || '(no url)'}`); }); - // Try to evaluate in chrome context - console.log('\n--- Testing chrome script execution ---'); + // Try to evaluate in privileged context + console.log('\n--- Testing privileged script execution ---'); const driver = firefox.getDriver(); const firstContext = contexts[0]; // Switch to chrome browsing context await driver.switchTo().window(firstContext.context); - console.log(`✓ Switched to chrome context: ${firstContext.context}`); + console.log(`✓ Switched to privileged context: ${firstContext.context}`); // Set Marionette context to chrome try { @@ -63,31 +63,28 @@ async function test() { // Now try to evaluate chrome-privileged script const appName = await driver.executeScript('return Services.appinfo.name;'); - console.log(`✓ Chrome script: Services.appinfo.name = "${appName}"`); + console.log(`✓ Privileged script: Services.appinfo.name = "${appName}"`); const version = await driver.executeScript('return Services.appinfo.version;'); - console.log(`✓ Chrome script: Services.appinfo.version = "${version}"`); + console.log(`✓ Privileged script: Services.appinfo.version = "${version}"`); const buildID = await driver.executeScript('return Services.appinfo.appBuildID;'); - console.log(`✓ Chrome script: Services.appinfo.appBuildID = "${buildID}"`); - - console.log('\n✅ Chrome context evaluation WORKS!'); + console.log(`✓ Privileged script: Services.appinfo.appBuildID = "${buildID}"`); + console.log('\n✅ Privileged context evaluation WORKS!'); } catch (err) { - console.log(`✗ Failed to set chrome context: ${err.message}`); - console.log(' Your Firefox build may not support chrome context'); + console.log(`✗ Failed to set privileged context: ${err.message}`); + console.log(' Your Firefox build may not support privileged context'); } - } else { - console.log(' No chrome contexts found (requires dev/nightly build)'); + console.log(' No privileged contexts found (requires dev/nightly build)'); } - } catch (err) { if (err.message && err.message.includes('UnsupportedOperationError')) { - console.log('✗ Chrome context not supported by this Firefox build'); + console.log('✗ Privileged context not supported by this Firefox build'); console.log(' Requires Firefox Nightly or custom build'); } else { - console.log(`✗ Chrome context test failed: ${err.message}`); + console.log(`✗ Privileged context test failed: ${err.message}`); } } @@ -95,7 +92,7 @@ async function test() { console.log('\n✓ Test completed'); } -test().catch(err => { +test().catch((err) => { console.error('\nTest failed:', err); console.error(err.stack); process.exit(1); diff --git a/tests/firefox/core-prefs.test.ts b/tests/firefox/core-prefs.test.ts index 395a384..3f81935 100644 --- a/tests/firefox/core-prefs.test.ts +++ b/tests/firefox/core-prefs.test.ts @@ -135,7 +135,7 @@ describe('FirefoxCore applyPreferences', () => { await expect(core.applyPreferences()).rejects.toThrow('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS'); }); - it('should get chrome contexts via BiDi', async () => { + it('should get privileged contexts via BiDi', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; // Setup sendBiDiCommand mock @@ -195,7 +195,7 @@ describe('FirefoxCore applyPreferences', () => { }); }); - it('should throw if no chrome contexts available', async () => { + it('should throw if no privileged contexts available', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); @@ -236,10 +236,10 @@ describe('FirefoxCore applyPreferences', () => { (core as any).driver = mockGetDriver(); (core as any).sendBiDiCommand = mockSendBiDiCommand; - await expect(core.applyPreferences()).rejects.toThrow('No chrome contexts'); + await expect(core.applyPreferences()).rejects.toThrow('No privileged contexts'); }); - it('should switch to chrome context and execute pref scripts', async () => { + it('should switch to privileged context and execute pref scripts', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; mockSendBiDiCommand.mockResolvedValue({ @@ -289,18 +289,14 @@ describe('FirefoxCore applyPreferences', () => { await core.applyPreferences(); - // Should have switched to chrome context + // Should have switched to privileged context expect(mockSwitchToWindow).toHaveBeenCalledWith('chrome-context-id'); expect(mockSetContext).toHaveBeenCalledWith('chrome'); // Should have executed scripts for each pref expect(mockExecuteScript).toHaveBeenCalledTimes(3); - expect(mockExecuteScript).toHaveBeenCalledWith( - 'Services.prefs.setBoolPref("bool.pref", true)' - ); - expect(mockExecuteScript).toHaveBeenCalledWith( - 'Services.prefs.setIntPref("int.pref", 42)' - ); + expect(mockExecuteScript).toHaveBeenCalledWith('Services.prefs.setBoolPref("bool.pref", true)'); + expect(mockExecuteScript).toHaveBeenCalledWith('Services.prefs.setIntPref("int.pref", 42)'); expect(mockExecuteScript).toHaveBeenCalledWith( 'Services.prefs.setStringPref("string.pref", "hello")' ); diff --git a/tests/tools/firefox-prefs.test.ts b/tests/tools/firefox-prefs.test.ts index 375f161..cfc2d60 100644 --- a/tests/tools/firefox-prefs.test.ts +++ b/tests/tools/firefox-prefs.test.ts @@ -105,10 +105,10 @@ describe('Firefox Prefs Tool Handlers', () => { expect(result.content[0].text).toContain('No preferences to set'); }); - it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no chrome contexts', async () => { + it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no privileged contexts', async () => { delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; - // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no chrome contexts are available + // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no privileged contexts are available mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); const mockFirefox = { @@ -154,9 +154,7 @@ describe('Firefox Prefs Tool Handlers', () => { expect(mockExecuteScript).toHaveBeenCalledWith( 'Services.prefs.setBoolPref("test.bool", true)' ); - expect(mockExecuteScript).toHaveBeenCalledWith( - 'Services.prefs.setIntPref("test.int", 42)' - ); + expect(mockExecuteScript).toHaveBeenCalledWith('Services.prefs.setIntPref("test.int", 42)'); expect(mockExecuteScript).toHaveBeenCalledWith( 'Services.prefs.setStringPref("test.string", "hello")' ); @@ -194,7 +192,7 @@ describe('Firefox Prefs Tool Handlers', () => { expect(result.content[0].text).toContain('Failed to set 1 preference(s)'); }); - it('should return error when no chrome contexts available', async () => { + it('should return error when no privileged contexts available', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); @@ -210,7 +208,7 @@ describe('Firefox Prefs Tool Handlers', () => { const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('No chrome contexts'); + expect(result.content[0].text).toContain('No privileged contexts'); }); it('should call getFirefox even when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not in process.env', async () => { @@ -252,10 +250,10 @@ describe('Firefox Prefs Tool Handlers', () => { expect(result.content[0].text).toContain('names parameter is required'); }); - it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no chrome contexts', async () => { + it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no privileged contexts', async () => { delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; - // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no chrome contexts are available + // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no privileged contexts are available mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); const mockFirefox = { @@ -328,7 +326,7 @@ describe('Firefox Prefs Tool Handlers', () => { expect(result.content[0].text).toContain('(not set)'); }); - it('should return error when no chrome contexts available', async () => { + it('should return error when no privileged contexts available', async () => { process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); @@ -344,7 +342,7 @@ describe('Firefox Prefs Tool Handlers', () => { const result = await handleGetFirefoxPrefs({ names: ['test.pref'] }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('No chrome contexts'); + expect(result.content[0].text).toContain('No privileged contexts'); }); it('should call getFirefox even when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not in process.env', async () => { From 9c36bf286c42b432d5fcc3d7068c4d5741a15371 Mon Sep 17 00:00:00 2001 From: Julian Descottes Date: Fri, 27 Mar 2026 16:54:00 +0100 Subject: [PATCH 19/20] Add flags to disable script evaluation and privileged contexts by default --- README.md | 2 ++ src/cli.ts | 12 +++++++ src/index.ts | 60 ++++++++++++++++++--------------- tests/cli/prefs-parsing.test.ts | 24 +++++++++++++ 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 62467fd..c1c2a86 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ You can pass flags or environment variables (names on the right): - `--connect-existing` — attach to an already-running Firefox instead of launching a new one (`CONNECT_EXISTING=true`) - `--marionette-port` — Marionette port for connect-existing mode, default 2828 (`MARIONETTE_PORT`) - `--pref name=value` — set Firefox preference at startup (repeatable, requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) +- `--enable-script` — enable the `evaluate_script` tool, which executes arbitrary JavaScript in the page context (`ENABLE_SCRIPT=true`) +- `--enable-privileged-context` — enable privileged context tools: list/select privileged contexts, evaluate privileged scripts, get/set Firefox prefs, and list extensions. Requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` (`ENABLE_PRIVILEGED_CONTEXT=true`) > **Note on `--pref`:** When Firefox runs in automation, it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that modify browser behavior for testing. The `--pref` option allows overriding these defaults when needed. diff --git a/src/cli.ts b/src/cli.ts index f6c9652..e11e36c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -127,6 +127,18 @@ export const cliOptions = { 'Set Firefox preference at startup (format: name=value). Can be specified multiple times. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1.', alias: 'p', }, + enableScript: { + type: 'boolean', + description: + 'Enable the evaluate_script tool, which allows executing arbitrary JavaScript in the page context.', + default: (process.env.ENABLE_SCRIPT ?? 'false') === 'true', + }, + enablePrivilegedContext: { + type: 'boolean', + description: + 'Enable privileged context tools: list/select privileged contexts, evaluate privileged scripts, get/set Firefox prefs, and list extensions. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1.', + default: (process.env.ENABLE_PRIVILEGED_CONTEXT ?? 'false') === 'true', + }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { diff --git a/src/index.ts b/src/index.ts index 140690f..0fd6dfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,9 +169,6 @@ const toolHandlers = new Map Promise Promise { }); }); +describe('CLI --enable-script flag', () => { + it('should default to false', () => { + const args = parseArguments('1.0.0', ['node', 'script']); + expect(args.enableScript).toBe(false); + }); + + it('should be true when --enable-script is passed', () => { + const args = parseArguments('1.0.0', ['node', 'script', '--enable-script']); + expect(args.enableScript).toBe(true); + }); +}); + +describe('CLI --enable-privileged-context flag', () => { + it('should default to false', () => { + const args = parseArguments('1.0.0', ['node', 'script']); + expect(args.enablePrivilegedContext).toBe(false); + }); + + it('should be true when --enable-privileged-context is passed', () => { + const args = parseArguments('1.0.0', ['node', 'script', '--enable-privileged-context']); + expect(args.enablePrivilegedContext).toBe(true); + }); +}); + describe('CLI --pref option', () => { it('should accept --pref argument', () => { const args = parseArguments('1.0.0', ['node', 'script', '--pref', 'test=value']); From d604b6d774901754e3225c95acb7c064067cbda5 Mon Sep 17 00:00:00 2001 From: Julian Descottes Date: Fri, 27 Mar 2026 18:12:27 +0100 Subject: [PATCH 20/20] Stop using privileged contexts to set initial preferences --- README.md | 2 +- docs/firefox-client.md | 4 +- src/cli.ts | 2 +- src/firefox/core.ts | 95 +------ src/firefox/types.ts | 2 +- tests/firefox/core-prefs.test.ts | 441 ++----------------------------- 6 files changed, 36 insertions(+), 510 deletions(-) diff --git a/README.md b/README.md index c1c2a86..0b3c0b2 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ You can pass flags or environment variables (names on the right): - `--accept-insecure-certs` — ignore TLS errors (`ACCEPT_INSECURE_CERTS=true`) - `--connect-existing` — attach to an already-running Firefox instead of launching a new one (`CONNECT_EXISTING=true`) - `--marionette-port` — Marionette port for connect-existing mode, default 2828 (`MARIONETTE_PORT`) -- `--pref name=value` — set Firefox preference at startup (repeatable, requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`) +- `--pref name=value` — set Firefox preference at startup via `moz:firefoxOptions` (repeatable) - `--enable-script` — enable the `evaluate_script` tool, which executes arbitrary JavaScript in the page context (`ENABLE_SCRIPT=true`) - `--enable-privileged-context` — enable privileged context tools: list/select privileged contexts, evaluate privileged scripts, get/set Firefox prefs, and list extensions. Requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` (`ENABLE_PRIVILEGED_CONTEXT=true`) diff --git a/docs/firefox-client.md b/docs/firefox-client.md index 21c0ff4..016e931 100644 --- a/docs/firefox-client.md +++ b/docs/firefox-client.md @@ -170,13 +170,13 @@ When Firefox runs in WebDriver BiDi mode (automated testing), it applies [Recomm **Setting preferences:** -At startup via CLI (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`): +At startup via CLI: ```bash # Enable ML/AI features like Smart Window npx firefox-devtools-mcp --pref "browser.ml.enable=true" ``` -At runtime via tools: +At runtime via tools (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`): ```javascript // Set preferences (e.g., enable ML features) await set_firefox_prefs({ prefs: { "browser.ml.enable": true } }); diff --git a/src/cli.ts b/src/cli.ts index e11e36c..db0b4df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -124,7 +124,7 @@ export const cliOptions = { type: 'array', string: true, description: - 'Set Firefox preference at startup (format: name=value). Can be specified multiple times. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1.', + 'Set Firefox preference at startup via moz:firefoxOptions (format: name=value). Can be specified multiple times.', alias: 'p', }, enableScript: { diff --git a/src/firefox/core.ts b/src/firefox/core.ts index 2e711ac..0e177ad 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -10,7 +10,6 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import type { FirefoxLaunchOptions } from './types.js'; import { log, logDebug } from '../utils/logger.js'; -import { generatePrefScript } from './pref-utils.js'; // --------------------------------------------------------------------------- // Shared driver interface — the minimal surface used by all consumers @@ -559,6 +558,11 @@ export class FirefoxCore { if (this.options.acceptInsecureCerts) { firefoxOptions.setAcceptInsecureCerts(true); } + if (this.options.prefs) { + for (const [name, value] of Object.entries(this.options.prefs)) { + firefoxOptions.setPreference(name, value); + } + } // Configure geckodriver service to capture output const serviceBuilder = new firefox.ServiceBuilder(); @@ -600,11 +604,6 @@ export class FirefoxCore { logDebug(`Navigated to: ${this.options.startUrl}`); } - // Apply preferences if configured - if (this.options.prefs && Object.keys(this.options.prefs).length > 0) { - await this.applyPreferences(); - } - log('✅ Firefox DevTools ready'); } @@ -676,90 +675,6 @@ export class FirefoxCore { return this.options; } - /** - * Apply Firefox preferences via Services.prefs API - * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable - */ - async applyPreferences(): Promise { - const prefs = this.options.prefs; - - // Return early if no prefs to set - if (!prefs || Object.keys(prefs).length === 0) { - return; - } - - // Check for MOZ_REMOTE_ALLOW_SYSTEM_ACCESS - if (!process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS) { - throw new Error( - 'MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable is required to set Firefox preferences at startup. ' + - 'Add --env MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 to your command line.' - ); - } - - if (!this.driver) { - throw new Error('Driver not connected'); - } - - // Get privileged ("chrome") contexts - const result = await this.sendBiDiCommand('browsingContext.getTree', { - 'moz:scope': 'chrome', - }); - - const contexts = result.contexts || []; - if (contexts.length === 0) { - throw new Error( - 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.' - ); - } - - const chromeContextId = contexts[0].context; - const originalContextId = this.currentContextId; - - const successes: string[] = []; - const failures: string[] = []; - - try { - // Switch to chrome context - await this.driver.switchTo().window(chromeContextId); - await (this.driver as any).setContext('chrome'); - - // Set each preference - for (const [name, value] of Object.entries(prefs)) { - try { - const script = generatePrefScript(name, value); - await this.driver.executeScript(script); - successes.push(`${name} = ${JSON.stringify(value)}`); - } catch (error) { - failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); - } - } - - // Log results - if (successes.length > 0) { - log(`✅ Applied ${successes.length} Firefox preference(s)`); - for (const msg of successes) { - logDebug(` ${msg}`); - } - } - if (failures.length > 0) { - log(`⚠ Failed to set ${failures.length} preference(s)`); - for (const msg of failures) { - logDebug(` ${msg}`); - } - } - } finally { - // Restore content context - try { - await (this.driver as any).setContext('content'); - if (originalContextId) { - await this.driver.switchTo().window(originalContextId); - } - } catch { - // Ignore errors restoring context - } - } - } - /** * Wait for WebSocket to be in OPEN state */ diff --git a/src/firefox/types.ts b/src/firefox/types.ts index 5a7f8b7..26e6856 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -62,7 +62,7 @@ export interface FirefoxLaunchOptions { marionettePort?: number | undefined; env?: Record | undefined; logFile?: string | undefined; - /** Firefox preferences to set at startup via Services.prefs API */ + /** Firefox preferences to set at startup via moz:firefoxOptions */ prefs?: Record | undefined; } diff --git a/tests/firefox/core-prefs.test.ts b/tests/firefox/core-prefs.test.ts index 3f81935..030217f 100644 --- a/tests/firefox/core-prefs.test.ts +++ b/tests/firefox/core-prefs.test.ts @@ -1,8 +1,8 @@ /** - * Tests for FirefoxCore applyPreferences method + * Tests for FirefoxCore --pref handling via moz:firefoxOptions */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock the index module to prevent actual Firefox connection const mockGetFirefox = vi.hoisted(() => vi.fn()); @@ -11,57 +11,28 @@ vi.mock('../../src/index.js', () => ({ getFirefox: mockGetFirefox, })); -describe('FirefoxCore applyPreferences', () => { - const mockExecuteScript = vi.fn(); - const mockSetContext = vi.fn(); - const mockSwitchToWindow = vi.fn(); - const mockSendBiDiCommand = vi.fn(); - const mockGetDriver = vi.fn(); +describe('FirefoxCore prefs via firefoxOptions', () => { + const mockSetPreference = vi.fn(); const mockGetWindowHandle = vi.fn(); - let originalEnv: string | undefined; - beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); - - // Store original env - originalEnv = process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; - - // Setup mock driver - mockGetDriver.mockReturnValue({ - switchTo: () => ({ - window: mockSwitchToWindow, - }), - setContext: mockSetContext, - executeScript: mockExecuteScript, - getWindowHandle: mockGetWindowHandle, - }); - mockGetWindowHandle.mockResolvedValue('content-context-id'); }); - afterEach(() => { - // Restore env - if (originalEnv !== undefined) { - process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = originalEnv; - } else { - delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; - } - }); - - it('should return early if no prefs', async () => { - // Mock selenium-webdriver + function mockSelenium(extraOptions: Record = {}) { vi.doMock('selenium-webdriver/firefox.js', () => ({ default: { Options: vi.fn(() => ({ enableBidi: vi.fn(), addArguments: vi.fn(), setBinary: vi.fn(), + setAcceptInsecureCerts: vi.fn(), + setPreference: mockSetPreference, + ...extraOptions, })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), + ServiceBuilder: vi.fn(() => ({ setStdio: vi.fn() })), }, })); @@ -77,203 +48,19 @@ describe('FirefoxCore applyPreferences', () => { })), Browser: { FIREFOX: 'firefox' }, })); + } + it('should not call setPreference when no prefs are provided', async () => { + mockSelenium(); const { FirefoxCore } = await import('../../src/firefox/core.js'); - - // Create core with no prefs const core = new FirefoxCore({ headless: true }); - - // Mock the driver as already connected - (core as any).driver = mockGetDriver(); - - // Call applyPreferences - should not throw, should not call BiDi - await core.applyPreferences(); - - // Should not have called sendBiDiCommand since no prefs - expect(mockSendBiDiCommand).not.toHaveBeenCalled(); - }); - - it('should throw if MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not set', async () => { - delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; - - vi.doMock('selenium-webdriver/firefox.js', () => ({ - default: { - Options: vi.fn(() => ({ - enableBidi: vi.fn(), - addArguments: vi.fn(), - setBinary: vi.fn(), - })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), - }, - })); - - vi.doMock('selenium-webdriver', () => ({ - Builder: vi.fn(() => ({ - forBrowser: vi.fn().mockReturnThis(), - setFirefoxOptions: vi.fn().mockReturnThis(), - setFirefoxService: vi.fn().mockReturnThis(), - build: vi.fn().mockResolvedValue({ - getWindowHandle: mockGetWindowHandle, - get: vi.fn().mockResolvedValue(undefined), - }), - })), - Browser: { FIREFOX: 'firefox' }, - })); - - const { FirefoxCore } = await import('../../src/firefox/core.js'); - - const core = new FirefoxCore({ - headless: true, - prefs: { 'test.pref': 'value' }, - }); - - // Mock the driver as connected - (core as any).driver = mockGetDriver(); - - await expect(core.applyPreferences()).rejects.toThrow('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS'); - }); - - it('should get privileged contexts via BiDi', async () => { - process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; - - // Setup sendBiDiCommand mock - mockSendBiDiCommand.mockResolvedValue({ - contexts: [{ context: 'chrome-context-id' }], - }); - - vi.doMock('selenium-webdriver/firefox.js', () => ({ - default: { - Options: vi.fn(() => ({ - enableBidi: vi.fn(), - addArguments: vi.fn(), - setBinary: vi.fn(), - })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), - }, - })); - - vi.doMock('selenium-webdriver', () => ({ - Builder: vi.fn(() => ({ - forBrowser: vi.fn().mockReturnThis(), - setFirefoxOptions: vi.fn().mockReturnThis(), - setFirefoxService: vi.fn().mockReturnThis(), - build: vi.fn().mockResolvedValue({ - getWindowHandle: mockGetWindowHandle, - get: vi.fn().mockResolvedValue(undefined), - getBidi: vi.fn().mockResolvedValue({ - socket: { - on: vi.fn(), - off: vi.fn(), - send: vi.fn(), - }, - }), - }), - })), - Browser: { FIREFOX: 'firefox' }, - })); - - const { FirefoxCore } = await import('../../src/firefox/core.js'); - - const core = new FirefoxCore({ - headless: true, - prefs: { 'test.pref': 'value' }, - }); - - // Mock internals - (core as any).driver = mockGetDriver(); - (core as any).sendBiDiCommand = mockSendBiDiCommand; - (core as any).currentContextId = 'content-context-id'; - - await core.applyPreferences(); - - expect(mockSendBiDiCommand).toHaveBeenCalledWith('browsingContext.getTree', { - 'moz:scope': 'chrome', - }); + await core.connect(); + expect(mockSetPreference).not.toHaveBeenCalled(); }); - it('should throw if no privileged contexts available', async () => { - process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; - - mockSendBiDiCommand.mockResolvedValue({ contexts: [] }); - - vi.doMock('selenium-webdriver/firefox.js', () => ({ - default: { - Options: vi.fn(() => ({ - enableBidi: vi.fn(), - addArguments: vi.fn(), - setBinary: vi.fn(), - })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), - }, - })); - - vi.doMock('selenium-webdriver', () => ({ - Builder: vi.fn(() => ({ - forBrowser: vi.fn().mockReturnThis(), - setFirefoxOptions: vi.fn().mockReturnThis(), - setFirefoxService: vi.fn().mockReturnThis(), - build: vi.fn().mockResolvedValue({ - getWindowHandle: mockGetWindowHandle, - get: vi.fn().mockResolvedValue(undefined), - }), - })), - Browser: { FIREFOX: 'firefox' }, - })); - + it('should call setPreference for each pref at startup', async () => { + mockSelenium(); const { FirefoxCore } = await import('../../src/firefox/core.js'); - - const core = new FirefoxCore({ - headless: true, - prefs: { 'test.pref': 'value' }, - }); - - (core as any).driver = mockGetDriver(); - (core as any).sendBiDiCommand = mockSendBiDiCommand; - - await expect(core.applyPreferences()).rejects.toThrow('No privileged contexts'); - }); - - it('should switch to privileged context and execute pref scripts', async () => { - process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; - - mockSendBiDiCommand.mockResolvedValue({ - contexts: [{ context: 'chrome-context-id' }], - }); - - vi.doMock('selenium-webdriver/firefox.js', () => ({ - default: { - Options: vi.fn(() => ({ - enableBidi: vi.fn(), - addArguments: vi.fn(), - setBinary: vi.fn(), - })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), - }, - })); - - vi.doMock('selenium-webdriver', () => ({ - Builder: vi.fn(() => ({ - forBrowser: vi.fn().mockReturnThis(), - setFirefoxOptions: vi.fn().mockReturnThis(), - setFirefoxService: vi.fn().mockReturnThis(), - build: vi.fn().mockResolvedValue({ - getWindowHandle: mockGetWindowHandle, - get: vi.fn().mockResolvedValue(undefined), - }), - })), - Browser: { FIREFOX: 'firefox' }, - })); - - const { FirefoxCore } = await import('../../src/firefox/core.js'); - const core = new FirefoxCore({ headless: true, prefs: { @@ -282,198 +69,22 @@ describe('FirefoxCore applyPreferences', () => { 'string.pref': 'hello', }, }); - - (core as any).driver = mockGetDriver(); - (core as any).sendBiDiCommand = mockSendBiDiCommand; - (core as any).currentContextId = 'content-context-id'; - - await core.applyPreferences(); - - // Should have switched to privileged context - expect(mockSwitchToWindow).toHaveBeenCalledWith('chrome-context-id'); - expect(mockSetContext).toHaveBeenCalledWith('chrome'); - - // Should have executed scripts for each pref - expect(mockExecuteScript).toHaveBeenCalledTimes(3); - expect(mockExecuteScript).toHaveBeenCalledWith('Services.prefs.setBoolPref("bool.pref", true)'); - expect(mockExecuteScript).toHaveBeenCalledWith('Services.prefs.setIntPref("int.pref", 42)'); - expect(mockExecuteScript).toHaveBeenCalledWith( - 'Services.prefs.setStringPref("string.pref", "hello")' - ); - }); - - it('should restore content context in finally block', async () => { - process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; - - mockSendBiDiCommand.mockResolvedValue({ - contexts: [{ context: 'chrome-context-id' }], - }); - - // Make executeScript throw to test finally block - mockExecuteScript.mockRejectedValue(new Error('Script error')); - - vi.doMock('selenium-webdriver/firefox.js', () => ({ - default: { - Options: vi.fn(() => ({ - enableBidi: vi.fn(), - addArguments: vi.fn(), - setBinary: vi.fn(), - })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), - }, - })); - - vi.doMock('selenium-webdriver', () => ({ - Builder: vi.fn(() => ({ - forBrowser: vi.fn().mockReturnThis(), - setFirefoxOptions: vi.fn().mockReturnThis(), - setFirefoxService: vi.fn().mockReturnThis(), - build: vi.fn().mockResolvedValue({ - getWindowHandle: mockGetWindowHandle, - get: vi.fn().mockResolvedValue(undefined), - }), - })), - Browser: { FIREFOX: 'firefox' }, - })); - - const { FirefoxCore } = await import('../../src/firefox/core.js'); - - const core = new FirefoxCore({ - headless: true, - prefs: { 'test.pref': 'value' }, - }); - - (core as any).driver = mockGetDriver(); - (core as any).sendBiDiCommand = mockSendBiDiCommand; - (core as any).currentContextId = 'content-context-id'; - - // Should complete even with errors (continues on per-pref errors) - await core.applyPreferences(); - - // Should have restored content context even after error - expect(mockSetContext).toHaveBeenLastCalledWith('content'); - expect(mockSwitchToWindow).toHaveBeenLastCalledWith('content-context-id'); - }); - - it('should call applyPreferences when prefs configured in connect()', async () => { - process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; - - mockSendBiDiCommand.mockResolvedValue({ - contexts: [{ context: 'chrome-context-id' }], - }); - - vi.doMock('selenium-webdriver/firefox.js', () => ({ - default: { - Options: vi.fn(() => ({ - enableBidi: vi.fn(), - addArguments: vi.fn(), - setBinary: vi.fn(), - })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), - }, - })); - - vi.doMock('selenium-webdriver', () => ({ - Builder: vi.fn(() => ({ - forBrowser: vi.fn().mockReturnThis(), - setFirefoxOptions: vi.fn().mockReturnThis(), - setFirefoxService: vi.fn().mockReturnThis(), - build: vi.fn().mockResolvedValue({ - getWindowHandle: mockGetWindowHandle, - get: vi.fn().mockResolvedValue(undefined), - switchTo: () => ({ - window: mockSwitchToWindow, - }), - setContext: mockSetContext, - executeScript: mockExecuteScript, - getBidi: vi.fn().mockResolvedValue({ - socket: { - on: vi.fn(), - off: vi.fn(), - send: vi.fn(), - }, - }), - }), - })), - Browser: { FIREFOX: 'firefox' }, - })); - - const { FirefoxCore } = await import('../../src/firefox/core.js'); - - const core = new FirefoxCore({ - headless: true, - prefs: { 'test.pref': 'value' }, - }); - - // Spy on applyPreferences - const applyPrefsSpy = vi.spyOn(core, 'applyPreferences').mockResolvedValue(); - await core.connect(); - - // applyPreferences should have been called during connect - expect(applyPrefsSpy).toHaveBeenCalled(); + expect(mockSetPreference).toHaveBeenCalledTimes(3); + expect(mockSetPreference).toHaveBeenCalledWith('bool.pref', true); + expect(mockSetPreference).toHaveBeenCalledWith('int.pref', 42); + expect(mockSetPreference).toHaveBeenCalledWith('string.pref', 'hello'); }); - it('should continue on per-pref errors and log failures', async () => { - process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1'; - - mockSendBiDiCommand.mockResolvedValue({ - contexts: [{ context: 'chrome-context-id' }], - }); - - // First pref fails, second succeeds - mockExecuteScript - .mockRejectedValueOnce(new Error('First pref error')) - .mockResolvedValueOnce(undefined); - - vi.doMock('selenium-webdriver/firefox.js', () => ({ - default: { - Options: vi.fn(() => ({ - enableBidi: vi.fn(), - addArguments: vi.fn(), - setBinary: vi.fn(), - })), - ServiceBuilder: vi.fn(() => ({ - setStdio: vi.fn(), - })), - }, - })); - - vi.doMock('selenium-webdriver', () => ({ - Builder: vi.fn(() => ({ - forBrowser: vi.fn().mockReturnThis(), - setFirefoxOptions: vi.fn().mockReturnThis(), - setFirefoxService: vi.fn().mockReturnThis(), - build: vi.fn().mockResolvedValue({ - getWindowHandle: mockGetWindowHandle, - get: vi.fn().mockResolvedValue(undefined), - }), - })), - Browser: { FIREFOX: 'firefox' }, - })); - + it('should not require MOZ_REMOTE_ALLOW_SYSTEM_ACCESS', async () => { + delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS; + mockSelenium(); const { FirefoxCore } = await import('../../src/firefox/core.js'); - const core = new FirefoxCore({ headless: true, - prefs: { - 'failing.pref': 'will fail', - 'success.pref': 'will succeed', - }, + prefs: { 'test.pref': 'value' }, }); - - (core as any).driver = mockGetDriver(); - (core as any).sendBiDiCommand = mockSendBiDiCommand; - (core as any).currentContextId = 'content-context-id'; - - // Should not throw - errors are collected - await core.applyPreferences(); - - // Both prefs should have been attempted - expect(mockExecuteScript).toHaveBeenCalledTimes(2); + await expect(core.connect()).resolves.not.toThrow(); + expect(mockSetPreference).toHaveBeenCalledWith('test.pref', 'value'); }); });