diff --git a/README.md b/README.md index 508fe2b72..4e53799c1 100644 --- a/README.md +++ b/README.md @@ -508,6 +508,12 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** string - **Choices:** `stable`, `canary`, `beta`, `dev` +- **`--browser`** + Specify which browser to use. Defaults to Chrome. + - **Type:** string + - **Choices:** `chrome`, `edge` + - **Default:** `chrome` + - **`--logFile`/ `--log-file`** Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports. - **Type:** string @@ -750,6 +756,100 @@ For more details on remote debugging, see the [Chrome DevTools documentation](ht Please consult [these instructions](./docs/debugging-android.md). +### Additional browser support + +In addition to Chrome, Chrome DevTools MCP supports Microsoft Edge. Edge must be installed separately. It is not bundled or downloaded by the MCP server. + +#### Microsoft Edge + +Pass `--browser=edge` to use the Microsoft Edge browser: + +```json +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@latest", "--browser=edge"] + } + } +} +``` + +You can combine `--browser=edge` with other flags such as `--channel`, `--headless`, `--autoConnect`, and `--executablePath`. + +#### Edge channels + +Edge supports `stable`, `beta`, `dev`, and `canary` channels selected via `--channel`. + +```bash +npx chrome-devtools-mcp@latest --browser=edge --channel=beta +``` + +#### Edge executable paths + +The MCP server automatically detects Edge installations in the standard locations: + +| Platform | Stable channel path | +| ----------- | ---------------------------------------------------------------- | +| **Windows** | `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe` | +| **macOS** | `/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge` | +| **Linux** | `/opt/microsoft/msedge/msedge` or `/usr/bin/microsoft-edge` | + +If Edge is installed in a non-standard location, use `--executablePath` to specify the path manually. + +#### Edge user data directory + +When using `--browser=edge`, the user data directory pattern changes from `chrome-profile-$CHANNEL` to `edge-profile-$CHANNEL`: + +- Linux / macOS: `$HOME/.cache/chrome-devtools-mcp/edge-profile-$CHANNEL` +- Windows: `%HOMEPATH%/.cache/chrome-devtools-mcp/edge-profile-$CHANNEL` + +#### Auto-connecting to a running Edge instance + +Use `--autoConnect` with `--browser=edge` to connect to an already-running Edge instance: + +```json +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--autoConnect", + "--browser=edge" + ] + } + } +} +``` + +Before connecting, enable remote debugging in Edge by navigating to `edge://inspect/#remote-debugging`. + +#### Manual connection to a running Edge instance + +Edge uses the same remote debugging protocol as Chrome. Start Edge with a remote debugging port and connect via `--browser-url`: + +**Windows** + +```bash +"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\edge-profile-stable" +``` + +**macOS** + +```bash +/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --remote-debugging-port=9222 --user-data-dir=/tmp/edge-profile-stable +``` + +**Linux** + +```bash +/opt/microsoft/msedge/msedge --remote-debugging-port=9222 --user-data-dir=/tmp/edge-profile-stable +``` + +Then configure the MCP server with `--browser-url=http://127.0.0.1:9222`. + ## Known limitations See [Troubleshooting](./docs/troubleshooting.md). diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md index 9b9fbce46..6aed388fb 100644 --- a/skills/chrome-devtools/SKILL.md +++ b/skills/chrome-devtools/SKILL.md @@ -7,6 +7,8 @@ description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooti **Browser lifecycle**: Browser starts automatically on first tool call using a persistent Chrome profile. Configure via CLI args in the MCP server configuration: `npx chrome-devtools-mcp@latest --help`. +**Browser selection**: Defaults to Chrome stable. Use `--browser=edge` for Microsoft Edge, `--channel=stable|beta|dev|canary` for a specific channel, or `--executablePath` for a custom browser binary. + **Page selection**: Tools operate on the currently selected page. Use `list_pages` to see available pages, then `select_page` to switch context. **Element interaction**: Use `take_snapshot` to get page structure with element `uid`s. Each element has a unique `uid` for interaction. If an element isn't found, take a fresh snapshot - the element may have been removed or the page changed. diff --git a/src/McpContext.ts b/src/McpContext.ts index 4647c6118..68a9df99f 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -7,6 +7,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import {isExtensionUrl} from './browser.js'; import type {TargetUniverse} from './DevtoolsUtils.js'; import {UniverseManager} from './DevtoolsUtils.js'; import {McpPage} from './McpPage.js'; @@ -506,10 +507,7 @@ export class McpContext implements Context { const allTargets = await this.browser.targets(); const serviceWorkers = allTargets.filter(target => { - return ( - target.type() === 'service_worker' && - target.url().includes('chrome-extension://') - ); + return target.type() === 'service_worker' && isExtensionUrl(target.url()); }); for (const serviceWorker of serviceWorkers) { @@ -588,10 +586,7 @@ export class McpContext implements Context { const allTargets = this.browser.targets(); const extensionTargets = allTargets.filter(target => { - return ( - target.url().startsWith('chrome-extension://') && - target.type() === 'page' - ); + return isExtensionUrl(target.url()) && target.type() === 'page'; }); for (const target of extensionTargets) { diff --git a/src/McpResponse.ts b/src/McpResponse.ts index b6162c27c..29b7656e6 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -5,6 +5,7 @@ */ import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js'; +import {isExtensionUrl} from './browser.js'; import {ConsoleFormatter} from './formatters/ConsoleFormatter.js'; import {IssueFormatter} from './formatters/IssueFormatter.js'; import {NetworkFormatter} from './formatters/NetworkFormatter.js'; @@ -567,7 +568,7 @@ Call ${handleDialog.name} to handle it before continuing.`); const {regularPages, extensionPages} = allPages.reduce( (acc: {regularPages: Page[]; extensionPages: Page[]}, page: Page) => { - if (page.url().startsWith('chrome-extension://')) { + if (isExtensionUrl(page.url())) { acc.extensionPages.push(page); } else { acc.regularPages.push(page); diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index f81c9e208..895df9c21 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import os from 'node:os'; + import type {YargsOptions} from '../third_party/index.js'; import {yargs, hideBin} from '../third_party/index.js'; @@ -116,6 +118,12 @@ export const cliOptions = { choices: ['stable', 'canary', 'beta', 'dev'] as const, conflicts: ['browserUrl', 'wsEndpoint', 'executablePath'], }, + browser: { + type: 'string', + description: 'Specify which browser to use. Defaults to Chrome.', + choices: ['chrome', 'edge'] as const, + default: 'chrome', + }, logFile: { type: 'string', describe: @@ -272,6 +280,16 @@ export function parseArguments(version: string, argv = process.argv) { ) { args.channel = 'stable'; } + // Edge Canary is not available on Linux. + if ( + args.browser === 'edge' && + args.channel === 'canary' && + os.platform() === 'linux' + ) { + throw new Error( + `Edge Canary is not available on Linux. Use --executablePath to specify a custom Edge binary.`, + ); + } return true; }) .example([ @@ -291,6 +309,7 @@ export function parseArguments(version: string, argv = process.argv) { ['$0 --channel canary', 'Use Chrome Canary installed on this system'], ['$0 --channel dev', 'Use Chrome Dev installed on this system'], ['$0 --channel stable', 'Use stable Chrome installed on this system'], + ['$0 --browser edge', 'Use Microsoft Edge instead of Chrome'], ['$0 --logFile /tmp/log.txt', 'Save logs to a file'], ['$0 --help', 'Print CLI options'], [ @@ -323,6 +342,10 @@ export function parseArguments(version: string, argv = process.argv) { '$0 --auto-connect --channel=canary', 'Connect to a canary Chrome instance (Chrome 144+) running instead of launching a new instance', ], + [ + '$0 --auto-connect --browser=edge', + 'Connect to a stable Edge instance running instead of launching a new instance', + ], [ '$0 --no-usage-statistics', 'Do not send usage statistics https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics.', diff --git a/src/browser.ts b/src/browser.ts index 7deea75b4..700c9d7d8 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -9,6 +9,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { + resolveEdgeExecutablePath, + resolveEdgeUserDataDir, +} from './edgePaths.js'; import {logger} from './logger.js'; import type { Browser, @@ -21,17 +25,23 @@ import {puppeteer} from './third_party/index.js'; let browser: Browser | undefined; function makeTargetFilter(enableExtensions = false) { - const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']); + const ignoredPrefixes = new Set([ + 'chrome://', + 'chrome-untrusted://', + 'edge://', + 'edge-untrusted://', + ]); if (!enableExtensions) { ignoredPrefixes.add('chrome-extension://'); + ignoredPrefixes.add('edge-extension://'); } return function targetFilter(target: Target): boolean { - if (target.url() === 'chrome://newtab/') { + if (isBrowserNewTabUrl(target.url())) { return true; } // Could be the only page opened in the browser. - if (target.url().startsWith('chrome://inspect')) { + if (isBrowserInspectUrl(target.url())) { return true; } for (const prefix of ignoredPrefixes) { @@ -49,10 +59,11 @@ export async function ensureBrowserConnected(options: { wsHeaders?: Record; devtools: boolean; channel?: Channel; + browserKind?: BrowserKind; userDataDir?: string; enableExtensions?: boolean; }) { - const {channel, enableExtensions} = options; + const {channel, enableExtensions, browserKind = 'chrome'} = options; if (browser?.connected) { return browser; } @@ -72,7 +83,12 @@ export async function ensureBrowserConnected(options: { } else if (options.browserURL) { connectOptions.browserURL = options.browserURL; } else if (channel || options.userDataDir) { - const userDataDir = options.userDataDir; + let userDataDir = options.userDataDir; + // Puppeteer's runtime does not resolve Edge channels, so we find + // Edge's default user data dir ourselves for auto-connect. + if (!userDataDir && browserKind === 'edge' && channel) { + userDataDir = resolveEdgeUserDataDir(channel); + } if (userDataDir) { autoConnect = true; // TODO: re-expose this logic via Puppeteer. @@ -98,7 +114,7 @@ export async function ensureBrowserConnected(options: { connectOptions.browserWSEndpoint = browserWSEndpoint; } catch (error) { throw new Error( - `Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`, + `Could not connect to ${browserName(browserKind)} in ${userDataDir}. Check if ${browserName(browserKind)} is running and remote debugging is enabled by going to ${inspectUrl(browserKind)}.`, { cause: error, }, @@ -123,7 +139,7 @@ export async function ensureBrowserConnected(options: { browser = await puppeteer.connect(connectOptions); } catch (err) { throw new Error( - `Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, + `Could not connect to ${browserName(browserKind)}. ${autoConnect ? `Check if ${browserName(browserKind)} is running and remote debugging is enabled by going to ${inspectUrl(browserKind)}.` : `Check if ${browserName(browserKind)} is running.`}`, { cause: err, }, @@ -137,6 +153,7 @@ interface McpLaunchOptions { acceptInsecureCerts?: boolean; executablePath?: string; channel?: Channel; + browserKind?: BrowserKind; userDataDir?: string; headless: boolean; isolated: boolean; @@ -171,11 +188,18 @@ export function detectDisplay(): void { } export async function launch(options: McpLaunchOptions): Promise { - const {channel, executablePath, headless, isolated} = options; + const { + channel, + executablePath, + headless, + isolated, + browserKind = 'chrome', + } = options; + const browserPrefix = browserKind === 'edge' ? 'edge' : 'chrome'; const profileDirName = channel && channel !== 'stable' - ? `chrome-profile-${channel}` - : 'chrome-profile'; + ? `${browserPrefix}-profile-${channel}` + : `${browserPrefix}-profile`; let userDataDir = options.userDataDir; if (!isolated && !userDataDir) { @@ -204,11 +228,19 @@ export async function launch(options: McpLaunchOptions): Promise { if (options.devtools) { args.push('--auto-open-devtools-for-tabs'); } - if (!executablePath) { - puppeteerChannel = - channel && channel !== 'stable' - ? (`chrome-${channel}` as ChromeReleaseChannel) - : 'chrome'; + let resolvedExecutablePath = executablePath; + if (!resolvedExecutablePath) { + if (browserKind === 'edge') { + // Puppeteer's runtime does not resolve Edge channels, so we find the + // Edge executable ourselves and pass it as executablePath. + const edgeChannel = channel ?? 'stable'; + resolvedExecutablePath = resolveEdgeExecutablePath(edgeChannel); + } else { + puppeteerChannel = + channel && channel !== 'stable' + ? (`chrome-${channel}` as ChromeReleaseChannel) + : 'chrome'; + } } if (!headless) { @@ -219,7 +251,7 @@ export async function launch(options: McpLaunchOptions): Promise { const browser = await puppeteer.launch({ channel: puppeteerChannel, targetFilter: makeTargetFilter(options.enableExtensions), - executablePath, + executablePath: resolvedExecutablePath, defaultViewport: null, userDataDir, pipe: true, @@ -271,3 +303,34 @@ export async function ensureBrowserLaunched( } export type Channel = 'stable' | 'canary' | 'beta' | 'dev'; + +export type BrowserKind = 'chrome' | 'edge'; + +export function browserName(browserKind: BrowserKind): string { + return browserKind === 'edge' ? 'Edge' : 'Chrome'; +} + +export function inspectUrl(browserKind: BrowserKind): string { + return browserKind === 'edge' + ? 'edge://inspect/#remote-debugging' + : 'chrome://inspect/#remote-debugging'; +} + +export function isExtensionUrl(url: string): boolean { + return ( + url.startsWith('chrome-extension://') || url.startsWith('edge-extension://') + ); +} + +export function isBrowserNewTabUrl(url: string): boolean { + return url === 'chrome://newtab/' || url === 'edge://newtab/'; +} + +export function isBrowserInspectUrl(url: string): boolean { + return url.startsWith('chrome://inspect') || url.startsWith('edge://inspect'); +} + +export { + resolveEdgeExecutablePath, + resolveEdgeUserDataDir, +} from './edgePaths.js'; diff --git a/src/edgePaths.ts b/src/edgePaths.ts new file mode 100644 index 000000000..cbfa6cdeb --- /dev/null +++ b/src/edgePaths.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +type Channel = 'stable' | 'canary' | 'beta' | 'dev'; + +function win32EdgeExe(envVar: string, fallback: string, folder: string) { + if (!process.env[envVar] && !fallback) { + return ''; + } + return path.join( + process.env[envVar] ?? fallback, + 'Microsoft', + folder, + 'Application', + 'msedge.exe', + ); +} + +function win32EdgeExePaths(folder: string): string[] { + return [ + win32EdgeExe('PROGRAMFILES(X86)', 'C:\\Program Files (x86)', folder), + win32EdgeExe('LOCALAPPDATA', '', folder) + ].filter(p => p); // Filter out empty paths if env vars are missing +} + +const EDGE_EXECUTABLE_PATHS: Record< + string, + Partial> +> = { + win32: { + stable: win32EdgeExePaths('Edge'), + beta: win32EdgeExePaths('Edge Beta'), + dev: win32EdgeExePaths('Edge Dev'), + canary: process.env['LOCALAPPDATA'] + ? [win32EdgeExe('LOCALAPPDATA', '', 'Edge SxS')] + : [], + }, + darwin: { + stable: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'], + beta: [ + '/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta', + ], + dev: [ + '/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev', + ], + canary: [ + '/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary', + ], + }, + linux: { + stable: ['/opt/microsoft/msedge/msedge', '/usr/bin/microsoft-edge'], + beta: ['/opt/microsoft/msedge-beta/msedge', '/usr/bin/microsoft-edge-beta'], + dev: ['/opt/microsoft/msedge-dev/msedge', '/usr/bin/microsoft-edge-dev'], + }, +}; + +export function resolveEdgeExecutablePath(channel: Channel): string { + const platform = os.platform(); + const paths = EDGE_EXECUTABLE_PATHS[platform]?.[channel]; + if (!paths || paths.length === 0) { + throw new Error(`Edge ${channel} channel is not available on ${platform}.`); + } + for (const candidate of paths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + const channelName = + channel === 'stable' + ? 'Edge' + : `Edge ${channel[0].toUpperCase() + channel.slice(1)}`; + throw new Error( + `Could not find Microsoft ${channelName} executable. Tried:\n` + + paths.map(p => ` ${p}`).join('\n') + + `\nInstall ${channelName} or use --executablePath to specify the path manually.`, + ); +} + +function win32EdgeUserDataDir(folder: string): string | undefined { + if (!process.env['LOCALAPPDATA']) { + return undefined; + } + return path.join( + process.env['LOCALAPPDATA'], + 'Microsoft', + folder, + 'User Data', + ); +} + +const EDGE_USER_DATA_DIRS: Record>> = { + win32: { + ...(process.env['LOCALAPPDATA'] + ? { + stable: win32EdgeUserDataDir('Edge')!, + beta: win32EdgeUserDataDir('Edge Beta')!, + dev: win32EdgeUserDataDir('Edge Dev')!, + canary: win32EdgeUserDataDir('Edge SxS')!, + } + : {}), + }, + darwin: { + stable: path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Microsoft Edge', + ), + beta: path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Microsoft Edge Beta', + ), + dev: path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Microsoft Edge Dev', + ), + canary: path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Microsoft Edge Canary', + ), + }, + linux: { + stable: path.join(os.homedir(), '.config', 'microsoft-edge'), + beta: path.join(os.homedir(), '.config', 'microsoft-edge-beta'), + dev: path.join(os.homedir(), '.config', 'microsoft-edge-dev'), + }, +}; + +export function resolveEdgeUserDataDir(channel: Channel): string { + const platform = os.platform(); + const dir = EDGE_USER_DATA_DIRS[platform]?.[channel]; + if (!dir) { + throw new Error(`Edge ${channel} channel is not available on ${platform}.`); + } + return dir; +} diff --git a/src/index.ts b/src/index.ts index 1e731521c..870a80c01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import type fs from 'node:fs'; import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js'; -import type {Channel} from './browser.js'; +import type {Channel, BrowserKind} from './browser.js'; import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger} from './logger.js'; @@ -67,6 +67,8 @@ export async function createMcpServer( chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`); } const devtools = serverArgs.experimentalDevtools ?? false; + const browserKind: BrowserKind = + serverArgs.browser === 'edge' ? 'edge' : 'chrome'; const browser = serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect ? await ensureBrowserConnected({ @@ -77,6 +79,7 @@ export async function createMcpServer( channel: serverArgs.autoConnect ? (serverArgs.channel as Channel) : undefined, + browserKind, userDataDir: serverArgs.userDataDir, devtools, }) @@ -84,6 +87,7 @@ export async function createMcpServer( headless: serverArgs.headless, executablePath: serverArgs.executablePath, channel: serverArgs.channel as Channel, + browserKind, isolated: serverArgs.isolated ?? false, userDataDir: serverArgs.userDataDir, logFile: options.logFile, diff --git a/tests/browser.test.ts b/tests/browser.test.ts index b0835bf96..4c1036636 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -5,13 +5,25 @@ */ import assert from 'node:assert'; +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {describe, it} from 'node:test'; import {executablePath} from 'puppeteer'; -import {detectDisplay, ensureBrowserConnected, launch} from '../src/browser.js'; +import { + detectDisplay, + ensureBrowserConnected, + isExtensionUrl, + isBrowserNewTabUrl, + isBrowserInspectUrl, + browserName, + inspectUrl, + launch, + resolveEdgeExecutablePath, + resolveEdgeUserDataDir, +} from '../src/browser.js'; describe('browser', () => { it('detects display does not crash', () => { @@ -101,3 +113,362 @@ describe('browser', () => { } }); }); + +describe('browserName', () => { + it('returns correct browser names', () => { + assert.strictEqual(browserName('chrome'), 'Chrome'); + assert.strictEqual(browserName('edge'), 'Edge'); + }); +}); + +describe('inspectUrl', () => { + it('returns correct inspect URLs', () => { + assert.strictEqual( + inspectUrl('chrome'), + 'chrome://inspect/#remote-debugging', + ); + assert.strictEqual(inspectUrl('edge'), 'edge://inspect/#remote-debugging'); + }); +}); + +describe('isExtensionUrl', () => { + it('detects chrome extension URLs', () => { + assert.strictEqual( + isExtensionUrl('chrome-extension://abcdef/popup.html'), + true, + ); + }); + + it('detects edge extension URLs', () => { + assert.strictEqual( + isExtensionUrl('edge-extension://abcdef/popup.html'), + true, + ); + }); + + it('rejects non-extension URLs', () => { + assert.strictEqual(isExtensionUrl('https://example.com'), false); + assert.strictEqual(isExtensionUrl('chrome://settings'), false); + assert.strictEqual(isExtensionUrl('edge://settings'), false); + }); +}); + +describe('isBrowserNewTabUrl', () => { + it('detects new tab URLs for both browsers', () => { + assert.strictEqual(isBrowserNewTabUrl('chrome://newtab/'), true); + assert.strictEqual(isBrowserNewTabUrl('edge://newtab/'), true); + assert.strictEqual(isBrowserNewTabUrl('https://example.com'), false); + }); +}); + +describe('isBrowserInspectUrl', () => { + it('detects inspect URLs for both browsers', () => { + assert.strictEqual(isBrowserInspectUrl('chrome://inspect'), true); + assert.strictEqual( + isBrowserInspectUrl('chrome://inspect/#remote-debugging'), + true, + ); + assert.strictEqual(isBrowserInspectUrl('edge://inspect'), true); + assert.strictEqual(isBrowserInspectUrl('https://example.com'), false); + }); +}); + +describe('ensureBrowserConnected Edge auto-connect', () => { + it('auto-connects to Edge via userDataDir', async () => { + // Use a temp dir (not the real Edge profile) to avoid conflicts with a + // running Edge instance. This mirrors the Chrome auto-connect test above. + let edgePath: string; + try { + edgePath = resolveEdgeExecutablePath('stable'); + } catch { + return; // Edge not installed — skip + } + + const folderPath = path.join( + os.tmpdir(), + `edge-autoconnect-${crypto.randomUUID()}`, + ); + let browser; + try { + browser = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + executablePath: edgePath, + devtools: false, + chromeArgs: ['--remote-debugging-port=0'], + }); + } catch { + return; // Edge found but not launchable — skip + } + try { + const connectedBrowser = await ensureBrowserConnected({ + userDataDir: folderPath, + browserKind: 'edge', + devtools: false, + }); + assert.ok(connectedBrowser); + assert.ok(connectedBrowser.connected); + connectedBrowser.disconnect(); + } finally { + await browser.close(); + } + }); + + it('auto-resolves Edge user data dir from channel when userDataDir not provided', async () => { + // Exercises the code path: browserKind='edge' + channel='stable' + no userDataDir + // → ensureBrowserConnected calls resolveEdgeUserDataDir(channel) internally. + let expectedDir: string; + try { + expectedDir = resolveEdgeUserDataDir('stable'); + } catch { + return; // Edge paths not available on this platform — skip + } + try { + await ensureBrowserConnected({ + channel: 'stable', + browserKind: 'edge', + devtools: false, + // No userDataDir — forces auto-resolution via resolveEdgeUserDataDir + }); + assert.fail('should have thrown (no running Edge expected)'); + } catch (err) { + // The error proves resolveEdgeUserDataDir was called: the resolved path + // appears in the error message ("Could not connect to Edge in "). + assert.ok( + err.message.includes(expectedDir), + `Error should reference resolved Edge user data dir "${expectedDir}": ${err.message}`, + ); + } + }); + + it('uses Edge-specific error message on connection failure', async () => { + const fakePath = path.join( + os.tmpdir(), + `edge-no-exist-${crypto.randomUUID()}`, + ); + await fs.promises.mkdir(fakePath, {recursive: true}); + try { + await ensureBrowserConnected({ + userDataDir: fakePath, + devtools: false, + browserKind: 'edge', + }); + assert.fail('should have thrown'); + } catch (err) { + assert.ok( + err.message.includes('Edge'), + `Error should mention Edge: ${err.message}`, + ); + assert.ok( + err.message.includes('edge://inspect'), + `Error should mention edge://inspect: ${err.message}`, + ); + } finally { + await fs.promises.rm(fakePath, {recursive: true, force: true}); + } + }); + + it('uses Chrome-specific error message on connection failure', async () => { + const fakePath = path.join( + os.tmpdir(), + `chrome-no-exist-${crypto.randomUUID()}`, + ); + await fs.promises.mkdir(fakePath, {recursive: true}); + try { + await ensureBrowserConnected({ + userDataDir: fakePath, + devtools: false, + browserKind: 'chrome', + }); + assert.fail('should have thrown'); + } catch (err) { + assert.ok( + err.message.includes('Chrome'), + `Error should mention Chrome: ${err.message}`, + ); + assert.ok( + err.message.includes('chrome://inspect'), + `Error should mention chrome://inspect: ${err.message}`, + ); + } finally { + await fs.promises.rm(fakePath, {recursive: true, force: true}); + } + }); +}); + +describe('launch Edge executable resolution', () => { + it('launches Edge via browserKind without executablePath', async () => { + try { + resolveEdgeExecutablePath('stable'); + } catch { + return; // Edge not installed — skip + } + + const tmpDir = os.tmpdir(); + const folderPath = path.join( + tmpDir, + `edge-launch-test-${crypto.randomUUID()}`, + ); + let browser; + try { + browser = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + browserKind: 'edge', + devtools: false, + }); + } catch { + return; // Edge found but not launchable — skip + } + try { + const [page] = await browser.pages(); + assert.ok(page); + } finally { + await browser.close(); + } + }); + + it('launches Edge beta via channel', async () => { + try { + resolveEdgeExecutablePath('beta'); + } catch { + return; // Edge Beta not installed — skip + } + + const tmpDir = os.tmpdir(); + const folderPath = path.join( + tmpDir, + `edge-beta-launch-${crypto.randomUUID()}`, + ); + let browser; + try { + browser = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + browserKind: 'edge', + channel: 'beta', + devtools: false, + }); + } catch { + return; // Edge Beta found but not launchable — skip + } + try { + const [page] = await browser.pages(); + assert.ok(page); + } finally { + await browser.close(); + } + }); + + it('launches Edge dev via channel', async () => { + try { + resolveEdgeExecutablePath('dev'); + } catch { + return; // Edge Dev not installed — skip + } + + const tmpDir = os.tmpdir(); + const folderPath = path.join( + tmpDir, + `edge-dev-launch-${crypto.randomUUID()}`, + ); + let browser; + try { + browser = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + browserKind: 'edge', + channel: 'dev', + devtools: false, + }); + } catch { + return; // Edge Dev found but not launchable — skip + } + try { + const [page] = await browser.pages(); + assert.ok(page); + } finally { + await browser.close(); + } + }); + + it('creates edge-profile directory prefix for Edge', async () => { + const tmpDir = os.tmpdir(); + const basePath = path.join( + tmpDir, + `edge-profile-test-${crypto.randomUUID()}`, + ); + await fs.promises.mkdir(basePath, {recursive: true}); + + try { + resolveEdgeExecutablePath('stable'); + } catch { + await fs.promises.rm(basePath, {recursive: true, force: true}); + return; // Edge not installed — skip + } + + const cliCacheDir = path.join( + os.homedir(), + '.cache', + 'chrome-devtools-mcp-cli', + ); + const entriesBefore = await fs.promises + .readdir(cliCacheDir) + .catch(() => [] as string[]); + + const browser = await launch({ + headless: true, + isolated: false, + browserKind: 'edge', + devtools: false, + viaCli: true, + }); + try { + // Verify profile dir starts with 'edge-profile' + const entries = await fs.promises.readdir(cliCacheDir); + assert.ok( + entries.some(e => e.startsWith('edge-profile')), + `Expected edge-profile* in ${cliCacheDir}, found: ${entries.join(', ')}`, + ); + } finally { + await browser.close(); + // Clean up any new edge-profile dirs created by this test + const entriesAfter = await fs.promises + .readdir(cliCacheDir) + .catch(() => [] as string[]); + const newEntries = entriesAfter.filter( + e => e.startsWith('edge-profile') && !entriesBefore.includes(e), + ); + for (const entry of newEntries) { + await fs.promises.rm(path.join(cliCacheDir, entry), { + recursive: true, + force: true, + }); + } + await fs.promises.rm(basePath, {recursive: true, force: true}); + } + }); + + it('uses channel-specific profile dir name', () => { + // Unit test: verify the profile dir naming logic inline + // The launch function uses `${browserPrefix}-profile-${channel}` for non-stable + const browserPrefix = 'edge'; + const cases = [ + {channel: 'stable', expected: 'edge-profile'}, + {channel: 'beta', expected: 'edge-profile-beta'}, + {channel: 'dev', expected: 'edge-profile-dev'}, + {channel: 'canary', expected: 'edge-profile-canary'}, + ]; + for (const {channel, expected} of cases) { + const profileDirName = + channel && channel !== 'stable' + ? `${browserPrefix}-profile-${channel}` + : `${browserPrefix}-profile`; + assert.strictEqual(profileDirName, expected, `channel=${channel}`); + } + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4bea6b954..3943a025d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -5,12 +5,14 @@ */ import assert from 'node:assert'; +import os from 'node:os'; import {describe, it} from 'node:test'; import {parseArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; describe('cli args parsing', () => { const defaultArgs = { + browser: 'chrome', 'category-emulation': true, categoryEmulation: true, 'category-performance': true, @@ -294,4 +296,141 @@ describe('cli args parsing', () => { ]); assert.strictEqual(disabledArgs.performanceCrux, false); }); + + it('parses --browser edge', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.channel, 'stable'); + }); + + it('parses --browser edge --channel beta', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + '--channel', + 'beta', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.channel, 'beta'); + }); + + it( + 'rejects --browser edge --channel canary on Linux', + {skip: os.platform() !== 'linux'}, + async () => { + // Yargs .check() calls process.exit() on validation failure instead of + // throwing, so we intercept process.exit to capture the rejection. + let exitCalled = false; + const originalExit = process.exit; + process.exit = (() => { + exitCalled = true; + }) as unknown as typeof process.exit; + try { + parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + '--channel', + 'canary', + ]); + } finally { + process.exit = originalExit; + } + assert.strictEqual( + exitCalled, + true, + 'Edge Canary on Linux should cause process.exit', + ); + }, + ); + + it( + 'accepts --browser edge --channel canary on non-Linux', + {skip: os.platform() === 'linux'}, + async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + '--channel', + 'canary', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.channel, 'canary'); + }, + ); + + it('accepts --browser edge --channel dev', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + '--channel', + 'dev', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.channel, 'dev'); + }); + + it('accepts --browser edge --channel stable explicitly', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + '--channel', + 'stable', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.channel, 'stable'); + }); + + it('defaults to stable channel for --browser edge', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.channel, 'stable'); + }); + + it('accepts --browser edge --auto-connect', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + '--auto-connect', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.autoConnect, true); + assert.strictEqual(args.channel, 'stable'); + }); + + it('accepts --browser edge --auto-connect --channel beta', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browser', + 'edge', + '--auto-connect', + '--channel', + 'beta', + ]); + assert.strictEqual(args.browser, 'edge'); + assert.strictEqual(args.autoConnect, true); + assert.strictEqual(args.channel, 'beta'); + }); }); diff --git a/tests/edgePaths.test.ts b/tests/edgePaths.test.ts new file mode 100644 index 000000000..6959854c3 --- /dev/null +++ b/tests/edgePaths.test.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import os from 'node:os'; +import {describe, it} from 'node:test'; + +import { + resolveEdgeExecutablePath, + resolveEdgeUserDataDir, +} from '../src/browser.js'; + +describe('resolveEdgeExecutablePath', () => { + it( + 'throws for canary channel on Linux', + {skip: os.platform() !== 'linux'}, + () => { + assert.throws(() => resolveEdgeExecutablePath('canary'), /not available/); + }, + ); +}); + +describe('resolveEdgeUserDataDir', () => { + it( + 'throws for canary channel on Linux', + {skip: os.platform() !== 'linux'}, + () => { + assert.throws(() => resolveEdgeUserDataDir('canary'), /not available/); + }, + ); + + it('returns a platform-specific path for stable', () => { + const result = resolveEdgeUserDataDir('stable'); + assert.strictEqual(typeof result, 'string'); + assert.ok(result.length > 0); + + const platform = os.platform(); + if (platform === 'win32') { + assert.ok(result.includes('Microsoft')); + assert.ok(result.includes('Edge')); + } else if (platform === 'darwin') { + assert.ok(result.includes('Microsoft Edge')); + } else if (platform === 'linux') { + assert.ok(result.includes('microsoft-edge')); + } + }); + + it('returns different paths for different channels', () => { + const stable = resolveEdgeUserDataDir('stable'); + const beta = resolveEdgeUserDataDir('beta'); + const dev = resolveEdgeUserDataDir('dev'); + assert.notStrictEqual(stable, beta); + assert.notStrictEqual(stable, dev); + assert.notStrictEqual(beta, dev); + }); +}); + +describe('resolveEdgeExecutablePath channels', () => { + it('returns different paths for different channels', () => { + const channels = ['stable', 'beta', 'dev'] as const; + const resolved: string[] = []; + for (const ch of channels) { + try { + resolved.push(resolveEdgeExecutablePath(ch)); + } catch { + // Channel not installed — skip this assertion + return; + } + } + // All resolved paths should be distinct + const unique = new Set(resolved); + assert.strictEqual( + unique.size, + resolved.length, + 'Each channel should resolve to a different path', + ); + }); + + it('error message includes candidate paths and install guidance', () => { + // Use a platform that has entries but with a nonexistent env to force failure + // We can't easily mock fs.existsSync, so test the error on a channel + // that is definitely not installed. If all channels are installed, skip. + const channels = ['stable', 'beta', 'dev'] as const; + for (const ch of channels) { + try { + resolveEdgeExecutablePath(ch); + // If it succeeds, this channel is installed — try next + } catch (err) { + // Verify error message quality + assert.ok( + err.message.includes('Could not find'), + `Should say 'Could not find': ${err.message}`, + ); + assert.ok( + err.message.includes('--executablePath'), + `Should mention --executablePath: ${err.message}`, + ); + return; // Verified — done + } + } + // All channels installed — can't test error path, that's OK + }); + + it( + 'resolves canary on non-Linux platforms', + {skip: os.platform() === 'linux'}, + () => { + try { + const result = resolveEdgeExecutablePath('canary'); + assert.strictEqual(typeof result, 'string'); + assert.ok(result.length > 0); + assert.ok( + result.includes('msedge'), + `Canary path should contain msedge: ${result}`, + ); + } catch { + // Edge Canary not installed — acceptable + } + }, + ); + + it('returns paths containing msedge', () => { + try { + const result = resolveEdgeExecutablePath('stable'); + assert.ok( + result.includes('msedge') || result.includes('microsoft-edge'), + `Path should reference Edge: ${result}`, + ); + } catch { + // Edge not installed — skip + } + }); +}); + +describe('resolveEdgeUserDataDir channels', () => { + it('returns platform-appropriate paths for beta', () => { + const result = resolveEdgeUserDataDir('beta'); + const platform = os.platform(); + if (platform === 'win32') { + assert.ok( + result.includes('Edge Beta'), + `Win32 beta should include 'Edge Beta': ${result}`, + ); + } else if (platform === 'darwin') { + assert.ok( + result.includes('Microsoft Edge Beta'), + `Darwin beta should include 'Microsoft Edge Beta': ${result}`, + ); + } else if (platform === 'linux') { + assert.ok( + result.includes('microsoft-edge-beta'), + `Linux beta should include 'microsoft-edge-beta': ${result}`, + ); + } + }); + + it('returns platform-appropriate paths for dev', () => { + const result = resolveEdgeUserDataDir('dev'); + const platform = os.platform(); + if (platform === 'win32') { + assert.ok( + result.includes('Edge Dev'), + `Win32 dev should include 'Edge Dev': ${result}`, + ); + } else if (platform === 'darwin') { + assert.ok( + result.includes('Microsoft Edge Dev'), + `Darwin dev should include 'Microsoft Edge Dev': ${result}`, + ); + } else if (platform === 'linux') { + assert.ok( + result.includes('microsoft-edge-dev'), + `Linux dev should include 'microsoft-edge-dev': ${result}`, + ); + } + }); + + it('canary resolves on non-Linux', {skip: os.platform() === 'linux'}, () => { + const result = resolveEdgeUserDataDir('canary'); + assert.strictEqual(typeof result, 'string'); + assert.ok(result.length > 0); + if (os.platform() === 'win32') { + assert.ok( + result.includes('Edge SxS'), + `Win32 canary should use Edge SxS: ${result}`, + ); + } else if (os.platform() === 'darwin') { + assert.ok( + result.includes('Microsoft Edge Canary'), + `Darwin canary should use Microsoft Edge Canary: ${result}`, + ); + } + }); + + it('throws with descriptive message for unsupported platform/channel', () => { + if (os.platform() === 'linux') { + assert.throws(() => resolveEdgeUserDataDir('canary'), /not available/); + } + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 4df17bb68..6d9361ba5 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -12,6 +12,7 @@ import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; import {executablePath} from 'puppeteer'; +import {resolveEdgeExecutablePath} from '../src/browser.js'; import type {ToolDefinition} from '../src/tools/ToolDefinition'; describe('e2e', () => { @@ -147,4 +148,24 @@ describe('e2e', () => { ['--experimental-interop-tools'], ); }); + + it('works with --browser edge', async () => { + let edgePath: string; + try { + edgePath = resolveEdgeExecutablePath('stable'); + } catch { + return; // Edge not installed — skip + } + + await withClient( + async client => { + const result = await client.callTool({ + name: 'list_pages', + arguments: {}, + }); + assert.ok(result.content); + }, + ['--browser', 'edge', '--executable-path', edgePath], + ); + }); });