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 4196ea6..0b3c0b2 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. @@ -95,6 +95,11 @@ 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 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`) + +> **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 @@ -126,6 +131,10 @@ 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 +- 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 ### Screenshot optimization for Claude Code @@ -195,4 +204,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/firefox-client.md b/docs/firefox-client.md index a2c47c0..016e931 100644 --- a/docs/firefox-client.md +++ b/docs/firefox-client.md @@ -141,7 +141,9 @@ 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 +--pref # Set Firefox preference (repeatable, requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1) ``` **Environment Variables:** @@ -151,9 +153,44 @@ 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 + +### 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 + +**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: +```bash +# Enable ML/AI features like Smart Window +npx firefox-devtools-mcp --pref "browser.ml.enable=true" +``` + +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 } }); + +// Get preference values +await get_firefox_prefs({ names: ["browser.ml.enable"] }); + +// Via restart_firefox +await restart_firefox({ prefs: { "browser.ml.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 @@ -188,6 +225,22 @@ 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`, `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 +- If Firefox is not running: configures options for next tool call that triggers launch + ✅ = Fully implemented ## Migration from RDP 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-lock.json b/package-lock.json index 2bfe1f8..8c43d59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "firefox-devtools-mcp", - "version": "0.7.4", + "name": "@padenot/firefox-devtools-mcp", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "firefox-devtools-mcp", - "version": "0.7.4", + "name": "@padenot/firefox-devtools-mcp", + "version": "0.8.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.1", diff --git a/package.json b/package.json index fd8e69e..c881d1c 100644 --- a/package.json +++ b/package.json @@ -95,12 +95,12 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/freema/firefox-devtools-mcp.git" + "url": "git+https://github.com/mozilla/firefox-devtools-mcp.git" }, "bugs": { - "url": "https://github.com/freema/firefox-devtools-mcp/issues" + "url": "https://github.com/mozilla/firefox-devtools-mcp/issues" }, - "homepage": "https://github.com/freema/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/cli.ts b/src/cli.ts index f1b4dad..db0b4df 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', @@ -66,6 +110,35 @@ 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/', + }, + pref: { + type: 'array', + string: true, + description: + 'Set Firefox preference at startup via moz:firefoxOptions (format: name=value). Can be specified multiple times.', + 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/firefox/core.ts b/src/firefox/core.ts index 0c68d86..0e177ad 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'; @@ -22,6 +25,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; @@ -62,6 +76,7 @@ export interface IDriver { perform(): Promise; clear(): Promise; }; + getBidi(): Promise; } /* eslint-enable @typescript-eslint/no-explicit-any */ @@ -419,6 +434,10 @@ class GeckodriverHttpDriver implements IDriver { kill(): void { this.gdProcess.kill(); } + + getBidi(): Promise { + throw new Error('BiDi not available in connect-existing mode'); + } } // --------------------------------------------------------------------------- @@ -463,6 +482,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 +505,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(); @@ -503,16 +550,41 @@ 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); } + 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(); + + // 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 +661,99 @@ 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; + } + + /** + * 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 + */ + async sendBiDiCommand(method: string, params: Record = {}): Promise { + if (!this.driver) { + throw new Error('Driver not connected'); + } + + const bidi = await this.driver.getBidi(); + const ws = 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 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 @@ -603,6 +768,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..d5460be 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 @@ -382,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 @@ -390,6 +406,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/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 688b62d..26e6856 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -60,6 +60,10 @@ export interface FirefoxLaunchOptions { acceptInsecureCerts?: boolean | undefined; connectExisting?: boolean | undefined; marionettePort?: number | undefined; + env?: Record | undefined; + logFile?: string | undefined; + /** Firefox preferences to set at startup via moz:firefoxOptions */ + prefs?: Record | undefined; } /** diff --git a/src/index.ts b/src/index.ts index c07225b..0fd6dfb 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'; @@ -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,29 @@ 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'); +} + +/** + * 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) { @@ -84,23 +108,56 @@ 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('='); + } + } + } + + // 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, + 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, + prefs, + }; + } 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 @@ -112,9 +169,6 @@ const toolHandlers = new Map Promise 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 (options.prefs && Object.keys(options.prefs).length > 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}`); + 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)', + }, + profilePath: { + type: 'string', + description: 'Firefox profile 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)', + }, + 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, 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: + // - Handles disconnected Firefox gracefully (resets stale reference) + // - Handles close() errors (we're restarting anyway) + // - Works both as initial start and restart + // - Always leaves system in a clean state for next tool call + + // 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('='); + } + } + } + + // 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 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, + 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', + prefs: mergedPrefs, + }; + + // 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(); + + // Prepare change summary + const changes = []; + 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)) { + 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')}` + ); + } 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, + profilePath: profilePath ?? args.profilePath ?? undefined, + env: newEnv, + headless: headless ?? false, + startUrl: startUrl ?? 'about:home', + }; + + 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)) { + config.push(` ${key}=${value}`); + } + } + if (headless) { + config.push('Headless: enabled'); + } + if (startUrl) { + config.push(`Start URL: ${startUrl}`); + } + + return successResponse( + `✅ Firefox configured. Will start on next tool call:\n${config.join('\n')}` + ); + } + } catch (error) { + return errorResponse(error as Error); + } +} diff --git a/src/tools/firefox-prefs.ts b/src/tools/firefox-prefs.ts new file mode 100644 index 0000000..d7d478e --- /dev/null +++ b/src/tools/firefox-prefs.ts @@ -0,0 +1,245 @@ +/** + * 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 a privileged 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'); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + // Get privileged ("chrome") contexts + const result = await firefox.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 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 a privileged 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'); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + // Get privileged ("chrome") contexts + const result = await firefox.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 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 09ce596..bf85871 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -16,8 +16,8 @@ export { handleClosePage, } from './pages.js'; -// Script evaluation tools - DISABLED (see docs/future-features.md) -// export { evaluateScriptTool, handleEvaluateScript } from './script.js'; +// Script evaluation tools +export { evaluateScriptTool, handleEvaluateScript } from './script.js'; // Console tools export { @@ -80,3 +80,41 @@ export { handleNavigateHistory, handleSetViewportSize, } from './utilities.js'; + +// Firefox management tools (logs, restart, info) +export { + getFirefoxLogsTool, + getFirefoxInfoTool, + restartFirefoxTool, + handleGetFirefoxLogs, + handleGetFirefoxInfo, + handleRestartFirefox, +} from './firefox-management.js'; + +// Privileged ("chrome") context tools +export { + listPrivilegedContextsTool, + selectPrivilegedContextTool, + evaluatePrivilegedScriptTool, + handleListPrivilegedContexts, + handleSelectPrivilegedContext, + handleEvaluatePrivilegedScript, +} from './privileged-context.js'; + +// Firefox preferences tools +export { + setFirefoxPrefsTool, + getFirefoxPrefsTool, + handleSetFirefoxPrefs, + handleGetFirefoxPrefs, +} from './firefox-prefs.js'; + +// WebExtension tools (install, uninstall, and list extensions) +export { + installExtensionTool, + uninstallExtensionTool, + listExtensionsTool, + handleInstallExtension, + handleUninstallExtension, + handleListExtensions, +} from './webextension.js'; diff --git a/src/tools/privileged-context.ts b/src/tools/privileged-context.ts new file mode 100644 index 0000000..e68b5c3 --- /dev/null +++ b/src/tools/privileged-context.ts @@ -0,0 +1,157 @@ +/** + * Privileged context management tools for MCP + * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 + */ + +import { successResponse, errorResponse } from '../utils/response-helpers.js'; +import type { McpToolResponse } from '../types/common.js'; + +export const listPrivilegedContextsTool = { + name: 'list_privileged_contexts', + description: + 'List privileged (privileged) browsing contexts. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Use restart_firefox with env parameter to enable.', + inputSchema: { + type: 'object', + properties: {}, + }, +}; + +export const selectPrivilegedContextTool = { + name: 'select_privileged_context', + description: + 'Select a privileged browsing context by ID and set WebDriver Classic context to "chrome" . Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.', + inputSchema: { + type: 'object', + properties: { + contextId: { + type: 'string', + description: 'Privileged browsing context ID from list_privileged_contexts', + }, + }, + required: ['contextId'], + }, +}; + +export const evaluatePrivilegedScriptTool = { + name: 'evaluate_privileged_script', + description: + 'Evaluate JavaScript in the current privileged context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Returns the result of the expression.', + inputSchema: { + type: 'object', + properties: { + expression: { + type: 'string', + description: 'JavaScript expression to evaluate in the privileged context', + }, + }, + required: ['expression'], + }, +}; + +function formatContextList(contexts: any[]): string { + if (contexts.length === 0) { + return '🔧 No privileged contexts found'; + } + + const lines: string[] = [`🔧 ${contexts.length} privileged contexts`]; + for (const ctx of contexts) { + const id = ctx.context; + const url = ctx.url || '(no url)'; + const children = ctx.children ? ` [${ctx.children.length} children]` : ''; + lines.push(` ${id}: ${url}${children}`); + } + return lines.join('\n'); +} + +export async function handleListPrivilegedContexts(_args: unknown): 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( + 'Privileged context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.' + ) + ); + } + return errorResponse(error as Error); + } +} + +export async function handleSelectPrivilegedContext(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.setContext('chrome'); + } catch (contextError) { + return errorResponse( + new Error( + `Switched to context ${contextId} but failed to set Marionette privileged context. Your Firefox build may not support privileged context or MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is not set.` + ) + ); + } + + return successResponse( + `✅ Switched to privileged context: ${contextId} (Marionette context set to privileged)` + ); + } catch (error) { + return errorResponse(error as Error); + } +} + +export async function handleEvaluatePrivilegedScript(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/webextension.ts b/src/tools/webextension.ts new file mode 100644 index 0000000..168ee0d --- /dev/null +++ b/src/tools/webextension.ts @@ -0,0 +1,344 @@ +/** + * WebExtension tools for MCP + * Tools for installing, managing and inspecting Firefox extensions + * + * install/uninstall: Uses Firefox's native WebDriver BiDi webExtension module + * list_extensions: Uses chrome-privileged AddonManager API as workaround for + * missing webExtension.getExtensions BiDi command + * + * Note: list_extensions requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 + */ + +import { successResponse, errorResponse } from '../utils/response-helpers.js'; +import type { McpToolResponse } from '../types/common.js'; + +// ============================================================================ +// Tool: install_extension +// ============================================================================ + +export const installExtensionTool = { + name: 'install_extension', + description: + 'Install a Firefox extension using WebDriver BiDi webExtension.install command. Supports installing from archive (.xpi/.zip), base64-encoded data, or unpacked directory.', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['archivePath', 'base64', 'path'], + description: + 'Extension data type: "archivePath" for .xpi/.zip, "base64" for encoded data, "path" for unpacked directory', + }, + path: { + type: 'string', + description: 'File path (for archivePath or path types)', + }, + value: { + type: 'string', + description: 'Base64-encoded extension data (for base64 type)', + }, + permanent: { + type: 'boolean', + description: + 'Firefox-specific: Install permanently (requires signed extension). Default: false (temporary install)', + }, + }, + required: ['type'], + }, +}; + +export async function handleInstallExtension(args: unknown): 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 || '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); + } +} + +// ============================================================================ +// Tool: list_extensions +// ============================================================================ + +export const listExtensionsTool = { + name: 'list_extensions', + description: + // MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is required because the tool relies on the + // privileged AddonManager API as a workaround for the currently missing + // webExtension.getExtensions WebDriver BiDi command. + 'List installed Firefox extensions with UUIDs and background scripts. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.', + inputSchema: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'string' }, + description: 'Optional: Filter by exact extension IDs (e.g., ["addon@example.com"])', + }, + name: { + type: 'string', + description: 'Optional: Filter by partial name match (case-insensitive, e.g., "shopify")', + }, + isActive: { + type: 'boolean', + description: 'Optional: Filter by enabled (true) or disabled (false) status', + }, + isSystem: { + type: 'boolean', + description: + 'Optional: Filter by system/built-in (true) or user-installed (false) extensions', + }, + }, + }, +}; + +interface ExtensionInfo { + id: string; + name: string; + version: string; + isActive: boolean; + isSystem: boolean; + uuid: string; + baseURL: string; + backgroundScripts: string[]; + manifestVersion: number | null; +} + +function formatExtensionList(extensions: ExtensionInfo[], filterId?: string): string { + if (extensions.length === 0) { + return filterId ? `🔍 Extension not found: ${filterId}` : '📦 No extensions installed'; + } + + const lines: string[] = [ + `📦 ${extensions.length} extension(s)${filterId ? ` (filtered by: ${filterId})` : ''}`, + ]; + + for (const ext of extensions) { + lines.push(''); + lines.push(` 📌 ${ext.name} (v${ext.version})`); + lines.push(` ID: ${ext.id}`); + lines.push(` Type: ${ext.isSystem ? '🔧 System/Built-in' : '👤 User-installed'}`); + lines.push(` UUID: ${ext.uuid}`); + lines.push(` Base URL: ${ext.baseURL}`); + lines.push(` Manifest: v${ext.manifestVersion || 'unknown'}`); + lines.push(` Active: ${ext.isActive ? '✅' : '❌'}`); + + if (ext.backgroundScripts.length > 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 privileged ("chrome") contexts + const result = await firefox.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 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); + } +} 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/tasks/99-specification.md b/tasks/99-specification.md deleted file mode 100644 index 245bd22..0000000 --- a/tasks/99-specification.md +++ /dev/null @@ -1,660 +0,0 @@ -# Firefox DevTools MCP Server - Aktuální Specifikace (2025) - -## Přehled projektu - -Model Context Protocol server pro ovládání a inspekci Firefox browseru přes **WebDriver BiDi protokol**. Umožňuje AI asistentům automatizovat Firefox s moderní architekturou založenou na W3C standardech. - -**Status:** ✅ Produkční implementace s kompletní funkcionalitou - -## Technologie - -- **Jazyk:** TypeScript/Node.js -- **Runtime:** Node.js 20.19.0+ -- **Browser automatizace:** Selenium WebDriver 4.36+ s BiDi protokolem -- **Protocol:** W3C WebDriver BiDi (nástupce RDP) -- **MCP SDK:** @modelcontextprotocol/sdk ^1.17.1 -- **Build:** tsup (esbuild wrapper) -- **Testing:** Vitest + manuální test skripty - -## Aktuální Architektura - -### Modulární struktura `src/firefox/` - -``` -src/firefox/ -├── index.ts # FirefoxClient - Public API facade -├── core.ts # FirefoxCore - WebDriver + BiDi connection -├── types.ts # TypeScript type definitions -├── events.ts # ConsoleEvents + NetworkEvents -├── dom.ts # DomInteractions - DOM manipulation -├── pages.ts # PageManagement - tabs, navigation, dialogs -└── snapshot/ # DOM snapshot system with UID mapping - ├── index.ts # Snapshot public interface - ├── manager.ts # SnapshotManager - caching & resolution - ├── formatter.ts # Text formatter for LLM consumption - ├── types.ts # Snapshot type definitions - └── injected/ # Browser-side injected scripts - ├── snapshot.injected.ts # Main injected entry point - ├── treeWalker.ts # DOM tree traversal - ├── elementCollector.ts # Element filtering & relevance - ├── attributeCollector.ts # ARIA & accessibility - └── selectorGenerator.ts # CSS & XPath generation -``` - -### Komponenty - -1. **FirefoxCore** (`core.ts`) - - Správa Selenium WebDriver instance - - BiDi protokol enablement - - Lifecycle management (launch, quit) - - Browsing context tracking - -2. **ConsoleEvents** (`events.ts`) - - BiDi `log.entryAdded` subscription - - Console message collection (debug, info, warn, error) - - Real-time WebSocket message parsing - -3. **NetworkEvents** (`events.ts`) - - BiDi network event subscriptions: - - `network.beforeRequestSent` - - `network.responseStarted` - - `network.responseCompleted` - - Request/response pairing - - Timing calculation - - Resource type detection - - Start/stop monitoring control - -4. **DomInteractions** (`dom.ts`) - - JavaScript evaluation via WebDriver - - Page content extraction - - Selector-based interactions (click, hover, fill, drag, upload) - - UID-based interactions (resolves snapshot UIDs to elements) - - Screenshot capture (full page + element) - -5. **PageManagement** (`pages.ts`) - - Navigation (URL, back, forward) - - Tab/window management - - Viewport resizing - - Dialog handling (alert, confirm, prompt) - -6. **SnapshotManager** (`snapshot/`) - - DOM tree snapshot with UIDs - - CSS & XPath selector generation - - Element caching with staleness detection - - ARIA attributes & accessibility info - - LLM-optimized text format - - Iframe support (same-origin only) - - Incremental snapshot IDs - -### Komunikace - -``` -Claude/AI Agent - ↓ (MCP Protocol - stdio) -MCP Server (src/index.ts) - ↓ (FirefoxClient API) -Selenium WebDriver - ↓ (WebDriver BiDi Protocol - WebSocket) -Firefox Browser (BiDi enabled) -``` - -## Implementované Funkce - -### 1. Browser Lifecycle ✅ - -**`FirefoxClient.connect()`** -- Spustí Firefox přes Selenium WebDriver -- Nastaví BiDi protokol -- Vrací: 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 new file mode 100755 index 0000000..e94c985 --- /dev/null +++ b/test-mozlog.js @@ -0,0 +1,112 @@ +#!/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: 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) => { + 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); +}); diff --git a/test-privileged-context.js b/test-privileged-context.js new file mode 100755 index 0000000..e26a014 --- /dev/null +++ b/test-privileged-context.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +import { FirefoxDevTools } from './dist/index.js'; + +async function test() { + console.log('=== Test: Privileged 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 privileged context via BiDi with moz:scope + console.log('\n--- Testing privileged context listing ---'); + + try { + // 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} privileged context(s) via BiDi`); + + if (contexts.length > 0) { + console.log(' Sample privileged contexts:'); + contexts.slice(0, 3).forEach((ctx) => { + console.log(` ${ctx.context}: ${ctx.url || '(no url)'}`); + }); + + // 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 privileged 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(`✓ Privileged script: Services.appinfo.name = "${appName}"`); + + const version = await driver.executeScript('return Services.appinfo.version;'); + console.log(`✓ Privileged script: Services.appinfo.version = "${version}"`); + + const buildID = await driver.executeScript('return Services.appinfo.appBuildID;'); + console.log(`✓ Privileged script: Services.appinfo.appBuildID = "${buildID}"`); + + console.log('\n✅ Privileged context evaluation WORKS!'); + } catch (err) { + console.log(`✗ Failed to set privileged context: ${err.message}`); + console.log(' Your Firefox build may not support privileged context'); + } + } else { + console.log(' No privileged contexts found (requires dev/nightly build)'); + } + } catch (err) { + if (err.message && err.message.includes('UnsupportedOperationError')) { + console.log('✗ Privileged context not supported by this Firefox build'); + console.log(' Requires Firefox Nightly or custom build'); + } else { + console.log(`✗ Privileged 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/tests/cli/prefs-parsing.test.ts b/tests/cli/prefs-parsing.test.ts new file mode 100644 index 0000000..eb00c93 --- /dev/null +++ b/tests/cli/prefs-parsing.test.ts @@ -0,0 +1,120 @@ +/** + * 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', () => { + it('should return empty object for undefined input', () => { + expect(parsePrefs(undefined)).toEqual({}); + }); + + it('should return empty object for empty array', () => { + expect(parsePrefs([])).toEqual({}); + }); + + it('should parse simple string preference', () => { + expect(parsePrefs(['some.pref=value'])).toEqual({ 'some.pref': 'value' }); + }); + + it('should parse boolean true', () => { + expect(parsePrefs(['some.pref=true'])).toEqual({ 'some.pref': true }); + }); + + it('should parse boolean false', () => { + expect(parsePrefs(['some.pref=false'])).toEqual({ 'some.pref': false }); + }); + + it('should parse integer', () => { + expect(parsePrefs(['some.pref=42'])).toEqual({ 'some.pref': 42 }); + }); + + it('should parse negative integer', () => { + expect(parsePrefs(['some.pref=-5'])).toEqual({ 'some.pref': -5 }); + }); + + it('should keep float as string (Firefox has no float pref)', () => { + expect(parsePrefs(['some.pref=3.14'])).toEqual({ 'some.pref': '3.14' }); + }); + + it('should handle value containing equals sign', () => { + expect(parsePrefs(['url=https://x.com?a=b'])).toEqual({ + url: 'https://x.com?a=b', + }); + }); + + it('should skip malformed entries', () => { + expect(parsePrefs(['malformed', 'valid=value'])).toEqual({ valid: 'value' }); + }); + + 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 --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']); + expect(args.pref).toContain('test=value'); + }); + + 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..030217f --- /dev/null +++ b/tests/firefox/core-prefs.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for FirefoxCore --pref handling via moz:firefoxOptions + */ + +import { describe, it, expect, vi, beforeEach } 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 prefs via firefoxOptions', () => { + const mockSetPreference = vi.fn(); + const mockGetWindowHandle = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + mockGetWindowHandle.mockResolvedValue('content-context-id'); + }); + + 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() })), + }, + })); + + 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 call setPreference when no prefs are provided', async () => { + mockSelenium(); + const { FirefoxCore } = await import('../../src/firefox/core.js'); + const core = new FirefoxCore({ headless: true }); + await core.connect(); + expect(mockSetPreference).not.toHaveBeenCalled(); + }); + + 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: { + 'bool.pref': true, + 'int.pref': 42, + 'string.pref': 'hello', + }, + }); + await core.connect(); + expect(mockSetPreference).toHaveBeenCalledTimes(3); + expect(mockSetPreference).toHaveBeenCalledWith('bool.pref', true); + expect(mockSetPreference).toHaveBeenCalledWith('int.pref', 42); + expect(mockSetPreference).toHaveBeenCalledWith('string.pref', 'hello'); + }); + + 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: { 'test.pref': 'value' }, + }); + await expect(core.connect()).resolves.not.toThrow(); + expect(mockSetPreference).toHaveBeenCalledWith('test.pref', 'value'); + }); +}); diff --git a/tests/firefox/core.test.ts b/tests/firefox/core.test.ts index 523c495..aece07c 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,235 @@ 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); + }); +}); + +// 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(); + }); +}); diff --git a/tests/firefox/pref-utils.test.ts b/tests/firefox/pref-utils.test.ts new file mode 100644 index 0000000..5a55879 --- /dev/null +++ b/tests/firefox/pref-utils.test.ts @@ -0,0 +1,40 @@ +/** + * Tests for Firefox preference utilities + */ + +import { describe, it, expect } from 'vitest'; +import { generatePrefScript } from '../../src/firefox/pref-utils.js'; + +describe('generatePrefScript', () => { + it('should generate setBoolPref for true', () => { + expect(generatePrefScript('p', true)).toBe('Services.prefs.setBoolPref("p", true)'); + }); + + it('should generate setBoolPref for false', () => { + expect(generatePrefScript('p', false)).toBe('Services.prefs.setBoolPref("p", false)'); + }); + + it('should generate setIntPref for number', () => { + expect(generatePrefScript('p', 42)).toBe('Services.prefs.setIntPref("p", 42)'); + }); + + it('should generate setStringPref for string', () => { + expect(generatePrefScript('p', 'v')).toBe('Services.prefs.setStringPref("p", "v")'); + }); + + 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..4a1cb37 --- /dev/null +++ b/tests/firefox/types.test.ts @@ -0,0 +1,32 @@ +/** + * Tests for Firefox type definitions + */ + +import { describe, it, expect } from 'vitest'; +import type { FirefoxLaunchOptions } from '../../src/firefox/types.js'; + +describe('FirefoxLaunchOptions', () => { + 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/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', }); diff --git a/tests/tools/firefox-management.test.ts b/tests/tools/firefox-management.test.ts new file mode 100644 index 0000000..f8467ca --- /dev/null +++ b/tests/tools/firefox-management.test.ts @@ -0,0 +1,259 @@ +/** + * Unit tests for Firefox management tools (restart_firefox, get_firefox_info, get_firefox_output) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +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()); +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, +})); + +const mockGetFirefox = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/index.js', () => ({ + args: mockArgs, + getFirefoxIfRunning: () => mockGetFirefoxIfRunning(), + setNextLaunchOptions: (opts: unknown) => mockSetNextLaunchOptions(opts), + resetFirefox: () => mockResetFirefox(), + getFirefox: () => mockGetFirefox(), +})); + +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'); + }); + + 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', () => { + 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'); + }); + + 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(); + }); + + 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..cfc2d60 --- /dev/null +++ b/tests/tools/firefox-prefs.test.ts @@ -0,0 +1,372 @@ +/** + * 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', () => { + it('should have correct name', () => { + expect(setFirefoxPrefsTool.name).toBe('set_firefox_prefs'); + }); + + 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', () => { + it('should have correct name', () => { + expect(getFirefoxPrefsTool.name).toBe('get_firefox_prefs'); + }); + + 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 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 privileged 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); + 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 privileged 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 privileged 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', () => { + 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 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 privileged 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); + 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 privileged 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 privileged 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(); + }); + }); +}); 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', () => {