mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-28 11:22:57 +08:00
refactor: extract browser in a BrowserManager class (#2787)
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {ParsedArguments} from './config/mcp-options.js';
|
||||
import type {
|
||||
Browser,
|
||||
ChromeReleaseChannel,
|
||||
LaunchOptions,
|
||||
} from './third_party/index.js';
|
||||
import {Mutex, puppeteer} from './third_party/index.js';
|
||||
import {logger, puppeteerLogger} from './utils/logger.js';
|
||||
import {isAllowedUrl} from './utils/url.js';
|
||||
|
||||
export interface BrowserManagerOptions {
|
||||
logFile?: fs.WriteStream;
|
||||
}
|
||||
|
||||
export class BrowserManager {
|
||||
#browser?: Browser;
|
||||
#browserMode?: 'launched' | 'connected';
|
||||
#initPromise?: Promise<Browser>;
|
||||
#mutex = new Mutex();
|
||||
#closingCount = 0;
|
||||
#serverArgs: ParsedArguments;
|
||||
#options: BrowserManagerOptions;
|
||||
|
||||
constructor(
|
||||
serverArgs: ParsedArguments,
|
||||
options: BrowserManagerOptions = {},
|
||||
) {
|
||||
this.#serverArgs = serverArgs;
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
static makeTargetFilter(enableExtensions = false) {
|
||||
return function targetFilter(target: {url(): string}): boolean {
|
||||
const url = target.url();
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
return isAllowedUrl(url, {categoryExtensions: enableExtensions});
|
||||
};
|
||||
}
|
||||
|
||||
static detectDisplay(): void {
|
||||
// Only detect display on Linux/UNIX.
|
||||
if (os.platform() === 'win32' || os.platform() === 'darwin') {
|
||||
return;
|
||||
}
|
||||
if (!process.env['DISPLAY']) {
|
||||
try {
|
||||
const result = execSync(
|
||||
`ps -u $(id -u) -o pid= | xargs -I{} cat /proc/{}/environ 2>/dev/null | tr '\\0' '\\n' | grep -m1 '^DISPLAY=' | cut -d= -f2`,
|
||||
);
|
||||
const display = result.toString('utf8').trim();
|
||||
process.env['DISPLAY'] = display;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome refuses to start as root unless the sandbox is explicitly disabled and
|
||||
* only says so on its stderr. Because we launch with `pipe: true`, Puppeteer
|
||||
* never surfaces that stderr and the failure reaches the client as an opaque
|
||||
* `Protocol error (Target.setDiscoverTargets): Target closed`. Detect the
|
||||
* situation and explain the way out instead. See https://crbug.com/638180.
|
||||
*
|
||||
* Returns `undefined` when the failure cannot be explained by running as root,
|
||||
* including on platforms without uids and when the sandbox was already disabled
|
||||
* through `--chrome-arg` (in which case root is not what stopped Chrome).
|
||||
*/
|
||||
static rootSandboxLaunchError(
|
||||
error: Error,
|
||||
args: readonly string[],
|
||||
uid = process.getuid?.(),
|
||||
): Error | undefined {
|
||||
if (uid !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
args.some(
|
||||
arg => arg === '--no-sandbox' || arg.startsWith('--no-sandbox='),
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return new Error(
|
||||
`Chrome failed to start: ${error.message}\n\n` +
|
||||
'chrome-devtools-mcp is running as root and Chrome does not start as root ' +
|
||||
'(https://crbug.com/638180). Run chrome-devtools-mcp as a non-root user; in a ' +
|
||||
'container, create an unprivileged user in the image and switch to it with ' +
|
||||
"USER. For the setup that Chrome's sandbox needs, see " +
|
||||
'https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox.',
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async ensureBrowser(): Promise<Browser> {
|
||||
if (this.#initPromise) {
|
||||
return await this.#initPromise;
|
||||
}
|
||||
const initPromise = this.#ensureBrowserLocked();
|
||||
this.#initPromise = initPromise;
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
if (this.#initPromise === initPromise) {
|
||||
this.#initPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #ensureBrowserLocked(): Promise<Browser> {
|
||||
if (this.#closingCount > 0) {
|
||||
throw new Error('Browser was closed while initializing.');
|
||||
}
|
||||
using _guard = await this.#mutex.acquire();
|
||||
if (this.#closingCount > 0) {
|
||||
throw new Error('Browser was closed while initializing.');
|
||||
}
|
||||
if (!this.#browser?.connected) {
|
||||
await this.#initBrowser();
|
||||
}
|
||||
if (this.#closingCount > 0 || !this.#browser) {
|
||||
await this.#closeBrowser();
|
||||
throw new Error('Browser was closed while initializing.');
|
||||
}
|
||||
return this.#browser;
|
||||
}
|
||||
|
||||
async #initBrowser(): Promise<Browser> {
|
||||
if (
|
||||
this.#serverArgs.browserUrl ||
|
||||
this.#serverArgs.wsEndpoint ||
|
||||
this.#serverArgs.autoConnect
|
||||
) {
|
||||
return await this.#connect();
|
||||
}
|
||||
return await this.#launch();
|
||||
}
|
||||
|
||||
async #launch(): Promise<Browser> {
|
||||
const {
|
||||
channel,
|
||||
executablePath,
|
||||
headless,
|
||||
isolated = false,
|
||||
categoryExtensions: enableExtensions,
|
||||
viaCli,
|
||||
viewport,
|
||||
acceptInsecureCerts,
|
||||
experimentalDevtools: devtools = false,
|
||||
blockedUrlPattern: blocklist,
|
||||
allowedUrlPattern: allowlist,
|
||||
ignoreDefaultChromeArg,
|
||||
proxyServer,
|
||||
} = this.#serverArgs;
|
||||
|
||||
const profileDirName =
|
||||
channel && channel !== 'stable'
|
||||
? `chrome-profile-${channel}`
|
||||
: 'chrome-profile';
|
||||
|
||||
let userDataDir = this.#serverArgs.userDataDir;
|
||||
if (!isolated && !userDataDir) {
|
||||
userDataDir = path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp',
|
||||
profileDirName,
|
||||
);
|
||||
await fs.promises.mkdir(userDataDir, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const args: LaunchOptions['args'] = [...(this.#serverArgs.chromeArg ?? [])];
|
||||
if (proxyServer) {
|
||||
args.push(`--proxy-server=${proxyServer}`);
|
||||
}
|
||||
args.push('--hide-crash-restore-bubble');
|
||||
|
||||
const ignoreDefaultArgs: LaunchOptions['ignoreDefaultArgs'] =
|
||||
ignoreDefaultChromeArg ?? false;
|
||||
|
||||
if (headless) {
|
||||
args.push('--screen-info={3840x2160}');
|
||||
}
|
||||
let puppeteerChannel: ChromeReleaseChannel | undefined;
|
||||
if (devtools) {
|
||||
args.push('--auto-open-devtools-for-tabs');
|
||||
}
|
||||
if (!executablePath) {
|
||||
puppeteerChannel =
|
||||
channel && channel !== 'stable' ? `chrome-${channel}` : 'chrome';
|
||||
}
|
||||
|
||||
if (!headless) {
|
||||
BrowserManager.detectDisplay();
|
||||
}
|
||||
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await puppeteer.launch({
|
||||
channel: puppeteerChannel,
|
||||
targetFilter: BrowserManager.makeTargetFilter(enableExtensions),
|
||||
executablePath,
|
||||
defaultViewport: null,
|
||||
userDataDir,
|
||||
pipe: true,
|
||||
headless,
|
||||
args,
|
||||
ignoreDefaultArgs,
|
||||
acceptInsecureCerts,
|
||||
handleDevToolsAsPage: true,
|
||||
enableExtensions,
|
||||
blocklist,
|
||||
allowlist,
|
||||
logger: puppeteerLogger,
|
||||
});
|
||||
if (this.#options.logFile) {
|
||||
// FIXME: we are probably subscribing too late to catch startup logs. We
|
||||
// should expose the process earlier or expose the getRecentLogs() getter.
|
||||
browser.process()?.stderr?.pipe(this.#options.logFile);
|
||||
browser.process()?.stdout?.pipe(this.#options.logFile);
|
||||
}
|
||||
if (viewport) {
|
||||
const [page] = await browser.pages();
|
||||
await page?.resize({
|
||||
contentWidth: viewport.width,
|
||||
contentHeight: viewport.height,
|
||||
});
|
||||
}
|
||||
this.#browserMode = 'launched';
|
||||
this.#browser = browser;
|
||||
return browser;
|
||||
} catch (error) {
|
||||
await browser?.close().catch(() => {
|
||||
// Best-effort cleanup if post-launch setup failed.
|
||||
});
|
||||
if (
|
||||
userDataDir &&
|
||||
error instanceof Error &&
|
||||
error.message.includes('The browser is already running')
|
||||
) {
|
||||
throw new Error(
|
||||
`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const rootError = BrowserManager.rootSandboxLaunchError(error, args);
|
||||
if (rootError) {
|
||||
throw rootError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #connect(): Promise<Browser> {
|
||||
const {
|
||||
browserUrl: browserURL,
|
||||
wsEndpoint,
|
||||
wsHeaders,
|
||||
autoConnect: isAutoConnect,
|
||||
userDataDir,
|
||||
blockedUrlPattern: blocklist,
|
||||
allowedUrlPattern: allowlist,
|
||||
} = this.#serverArgs;
|
||||
// Important: only pass channel, if autoConnect is true.
|
||||
const channel = isAutoConnect ? this.#serverArgs.channel : undefined;
|
||||
|
||||
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
|
||||
targetFilter: BrowserManager.makeTargetFilter(),
|
||||
defaultViewport: null,
|
||||
handleDevToolsAsPage: true,
|
||||
blocklist,
|
||||
allowlist,
|
||||
logger: puppeteerLogger,
|
||||
};
|
||||
|
||||
let autoConnect = false;
|
||||
if (wsEndpoint) {
|
||||
connectOptions.browserWSEndpoint = wsEndpoint;
|
||||
if (wsHeaders) {
|
||||
connectOptions.headers = wsHeaders;
|
||||
}
|
||||
} else if (browserURL) {
|
||||
connectOptions.browserURL = browserURL;
|
||||
} else if (channel || userDataDir) {
|
||||
if (userDataDir) {
|
||||
autoConnect = true;
|
||||
// TODO: re-expose this logic via Puppeteer.
|
||||
const portPath = path.join(userDataDir, 'DevToolsActivePort');
|
||||
try {
|
||||
const fileContent = await fs.promises.readFile(portPath, 'utf8');
|
||||
const [rawPort, rawPath] = fileContent
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
return line.trim();
|
||||
})
|
||||
.filter(line => {
|
||||
return !!line;
|
||||
});
|
||||
if (!rawPort || !rawPath) {
|
||||
throw new Error(
|
||||
`Invalid DevToolsActivePort '${fileContent}' found`,
|
||||
);
|
||||
}
|
||||
const port = parseInt(rawPort, 10);
|
||||
if (isNaN(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid port '${rawPort}' found`);
|
||||
}
|
||||
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
|
||||
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.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!channel) {
|
||||
throw new Error('Channel must be provided if userDataDir is missing');
|
||||
}
|
||||
connectOptions.channel =
|
||||
channel === 'stable' ? 'chrome' : `chrome-${channel}`;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Either browserURL, wsEndpoint, channel or userDataDir must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
logger?.('Connecting Puppeteer to ', JSON.stringify(connectOptions));
|
||||
try {
|
||||
const connected = await puppeteer.connect(connectOptions);
|
||||
logger?.('Connected Puppeteer');
|
||||
this.#browserMode = 'connected';
|
||||
this.#browser = connected;
|
||||
return connected;
|
||||
} 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.`}`,
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #closeBrowser(): Promise<void> {
|
||||
const browser = this.#browser;
|
||||
const mode = this.#browserMode;
|
||||
this.#browser = undefined;
|
||||
this.#browserMode = undefined;
|
||||
if (!browser || !browser.connected) {
|
||||
return;
|
||||
}
|
||||
if (mode === 'launched') {
|
||||
await browser.close().catch(err => {
|
||||
logger?.('Failed to close browser', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await browser.disconnect().catch(err => {
|
||||
logger?.('Failed to disconnect from browser', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown hook for the active browser. Closes a launched browser (so the
|
||||
* Chrome subprocess is reaped) or disconnects from an attached browser (so
|
||||
* the user's Chrome instance stays alive). No-op if no browser is active or
|
||||
* the connection has already been dropped.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.#initPromise = undefined;
|
||||
this.#closingCount++;
|
||||
using _guard = await this.#mutex.acquire();
|
||||
try {
|
||||
await this.#closeBrowser();
|
||||
} finally {
|
||||
this.#closingCount--;
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.dispose](): void {
|
||||
this.close().catch(err => {
|
||||
logger?.('Failed to dispose BrowserManager', err);
|
||||
});
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -60,7 +60,7 @@ interface McpContextOptions {
|
||||
// Whether source maps are enabled in DevTools.
|
||||
sourceMaps?: boolean;
|
||||
// The allow list of URL patterns to allow loading resources.
|
||||
allowList?: string[];
|
||||
allowlist?: string[];
|
||||
// The block list of URL patterns to block loading resources.
|
||||
blocklist?: string[];
|
||||
// Whether to skip path validation when the client did not negotiate the roots
|
||||
@@ -369,7 +369,7 @@ export class McpContext implements Context {
|
||||
}
|
||||
|
||||
get #hasNetworkBlockOrAllowlist(): boolean {
|
||||
return !!(this.#options.allowList || this.#options.blocklist);
|
||||
return !!(this.#options.allowlist || this.#options.blocklist);
|
||||
}
|
||||
|
||||
installPWA(options: InstallPWAOptions): Promise<string> {
|
||||
@@ -898,10 +898,10 @@ export class McpContext implements Context {
|
||||
}
|
||||
|
||||
#validateUrlAllowed(url: URL): void {
|
||||
if (!this.#options.allowList) {
|
||||
if (!this.#options.allowlist) {
|
||||
return;
|
||||
}
|
||||
for (const allow of this.#options.allowList) {
|
||||
for (const allow of this.#options.allowlist) {
|
||||
const pattern = new URLPattern(allow);
|
||||
if (pattern.test(url)) {
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,7 @@ import '../utils/polyfill.js';
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
import {closeBrowser} from '../browser.js';
|
||||
import {BrowserManager} from '../BrowserManager.js';
|
||||
import {McpServer, logDisclaimers} from '../index.js';
|
||||
import {ClearcutLogger} from '../telemetry/ClearcutLogger.js';
|
||||
import {computeFlagUsage} from '../telemetry/flagUtils.js';
|
||||
@@ -33,6 +33,14 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
|
||||
});
|
||||
}
|
||||
|
||||
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
|
||||
const browserManager = new BrowserManager(args, {
|
||||
logFile,
|
||||
});
|
||||
const serverPromise = McpServer.from(args, {
|
||||
browserManager,
|
||||
});
|
||||
|
||||
// Shutdown on stdin EOF (stdio MCP convention — the client closes the
|
||||
// transport to signal exit) and on standard termination signals. Without
|
||||
// this, an active Chrome subprocess keeps the Node event loop ref'd after
|
||||
@@ -52,7 +60,8 @@ async function shutdown(reason: string): Promise<void> {
|
||||
logger?.('Shutdown timeout exceeded, forcing exit');
|
||||
process.exit(0);
|
||||
}, 5000).unref();
|
||||
await closeBrowser();
|
||||
const server = await serverPromise;
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.stdin.on('end', () => {
|
||||
@@ -71,10 +80,7 @@ process.on('SIGHUP', () => {
|
||||
void shutdown('SIGHUP');
|
||||
});
|
||||
|
||||
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
|
||||
const server = await McpServer.from(args, {
|
||||
logFile,
|
||||
});
|
||||
const server = await serverPromise;
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
logger?.('Chrome DevTools MCP Server connected');
|
||||
|
||||
-348
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
Browser,
|
||||
ChromeReleaseChannel,
|
||||
LaunchOptions,
|
||||
} from './third_party/index.js';
|
||||
import {puppeteer} from './third_party/index.js';
|
||||
import {logger, puppeteerLogger} from './utils/logger.js';
|
||||
import {isAllowedUrl} from './utils/url.js';
|
||||
|
||||
let browser: Browser | undefined;
|
||||
let browserMode: 'launched' | 'connected' | undefined;
|
||||
|
||||
export function makeTargetFilter(enableExtensions = false) {
|
||||
return function targetFilter(target: {url(): string}): boolean {
|
||||
const url = target.url();
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
return isAllowedUrl(url, {categoryExtensions: enableExtensions});
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureBrowserConnected(options: {
|
||||
browserURL?: string;
|
||||
wsEndpoint?: string;
|
||||
wsHeaders?: Record<string, string>;
|
||||
devtools: boolean;
|
||||
channel?: Channel;
|
||||
userDataDir?: string;
|
||||
enableExtensions?: boolean;
|
||||
blocklist?: string[];
|
||||
allowlist?: string[];
|
||||
}) {
|
||||
const {channel, enableExtensions} = options;
|
||||
if (browser?.connected) {
|
||||
return browser;
|
||||
}
|
||||
|
||||
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
|
||||
targetFilter: makeTargetFilter(enableExtensions),
|
||||
defaultViewport: null,
|
||||
handleDevToolsAsPage: true,
|
||||
blocklist: options.blocklist,
|
||||
allowlist: options.allowlist,
|
||||
logger: puppeteerLogger,
|
||||
};
|
||||
|
||||
let autoConnect = false;
|
||||
if (options.wsEndpoint) {
|
||||
connectOptions.browserWSEndpoint = options.wsEndpoint;
|
||||
if (options.wsHeaders) {
|
||||
connectOptions.headers = options.wsHeaders;
|
||||
}
|
||||
} else if (options.browserURL) {
|
||||
connectOptions.browserURL = options.browserURL;
|
||||
} else if (channel || options.userDataDir) {
|
||||
const userDataDir = options.userDataDir;
|
||||
if (userDataDir) {
|
||||
autoConnect = true;
|
||||
// TODO: re-expose this logic via Puppeteer.
|
||||
const portPath = path.join(userDataDir, 'DevToolsActivePort');
|
||||
try {
|
||||
const fileContent = await fs.promises.readFile(portPath, 'utf8');
|
||||
const [rawPort, rawPath] = fileContent
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
return line.trim();
|
||||
})
|
||||
.filter(line => {
|
||||
return !!line;
|
||||
});
|
||||
if (!rawPort || !rawPath) {
|
||||
throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
|
||||
}
|
||||
const port = parseInt(rawPort, 10);
|
||||
if (isNaN(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid port '${rawPort}' found`);
|
||||
}
|
||||
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
|
||||
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.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!channel) {
|
||||
throw new Error('Channel must be provided if userDataDir is missing');
|
||||
}
|
||||
connectOptions.channel = (
|
||||
channel === 'stable' ? 'chrome' : `chrome-${channel}`
|
||||
) as ChromeReleaseChannel;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Either browserURL, wsEndpoint, channel or userDataDir must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
logger?.('Connecting Puppeteer to ', JSON.stringify(connectOptions));
|
||||
try {
|
||||
// Assign mode before browser so a concurrent closeBrowser() never sees
|
||||
// `browser` set with `browserMode` still undefined (would fall through
|
||||
// to the disconnect() path and orphan a launched Chrome).
|
||||
const connected = await puppeteer.connect(connectOptions);
|
||||
browserMode = 'connected';
|
||||
browser = connected;
|
||||
} 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.`}`,
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
}
|
||||
logger?.('Connected Puppeteer');
|
||||
return browser;
|
||||
}
|
||||
|
||||
interface McpLaunchOptions {
|
||||
acceptInsecureCerts?: boolean;
|
||||
executablePath?: string;
|
||||
channel?: Channel;
|
||||
userDataDir?: string;
|
||||
headless: boolean;
|
||||
isolated: boolean;
|
||||
logFile?: fs.WriteStream;
|
||||
viewport?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
chromeArgs?: string[];
|
||||
ignoreDefaultChromeArgs?: string[];
|
||||
devtools: boolean;
|
||||
enableExtensions?: boolean;
|
||||
viaCli?: boolean;
|
||||
blocklist?: string[];
|
||||
allowlist?: string[];
|
||||
}
|
||||
|
||||
export function detectDisplay(): void {
|
||||
// Only detect display on Linux/UNIX.
|
||||
if (os.platform() === 'win32' || os.platform() === 'darwin') {
|
||||
return;
|
||||
}
|
||||
if (!process.env['DISPLAY']) {
|
||||
try {
|
||||
const result = execSync(
|
||||
`ps -u $(id -u) -o pid= | xargs -I{} cat /proc/{}/environ 2>/dev/null | tr '\\0' '\\n' | grep -m1 '^DISPLAY=' | cut -d= -f2`,
|
||||
);
|
||||
const display = result.toString('utf8').trim();
|
||||
process.env['DISPLAY'] = display;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome refuses to start as root unless the sandbox is explicitly disabled and
|
||||
* only says so on its stderr. Because we launch with `pipe: true`, Puppeteer
|
||||
* never surfaces that stderr and the failure reaches the client as an opaque
|
||||
* `Protocol error (Target.setDiscoverTargets): Target closed`. Detect the
|
||||
* situation and explain the way out instead. See https://crbug.com/638180.
|
||||
*
|
||||
* Returns `undefined` when the failure cannot be explained by running as root,
|
||||
* including on platforms without uids and when the sandbox was already disabled
|
||||
* through `--chrome-arg` (in which case root is not what stopped Chrome).
|
||||
*
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function rootSandboxLaunchError(
|
||||
error: Error,
|
||||
args: readonly string[],
|
||||
uid = process.getuid?.(),
|
||||
): Error | undefined {
|
||||
if (uid !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
args.some(arg => arg === '--no-sandbox' || arg.startsWith('--no-sandbox='))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return new Error(
|
||||
`Chrome failed to start: ${error.message}\n\n` +
|
||||
'chrome-devtools-mcp is running as root and Chrome does not start as root ' +
|
||||
'(https://crbug.com/638180). Run chrome-devtools-mcp as a non-root user; in a ' +
|
||||
'container, create an unprivileged user in the image and switch to it with ' +
|
||||
"USER. For the setup that Chrome's sandbox needs, see " +
|
||||
'https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox.',
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function launch(options: McpLaunchOptions): Promise<Browser> {
|
||||
const {channel, executablePath, headless, isolated} = options;
|
||||
const profileDirName =
|
||||
channel && channel !== 'stable'
|
||||
? `chrome-profile-${channel}`
|
||||
: 'chrome-profile';
|
||||
|
||||
let userDataDir = options.userDataDir;
|
||||
if (!isolated && !userDataDir) {
|
||||
userDataDir = path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
options.viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp',
|
||||
profileDirName,
|
||||
);
|
||||
await fs.promises.mkdir(userDataDir, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const args: LaunchOptions['args'] = [
|
||||
...(options.chromeArgs ?? []),
|
||||
'--hide-crash-restore-bubble',
|
||||
];
|
||||
const ignoreDefaultArgs: LaunchOptions['ignoreDefaultArgs'] =
|
||||
options.ignoreDefaultChromeArgs ?? false;
|
||||
|
||||
if (headless) {
|
||||
args.push('--screen-info={3840x2160}');
|
||||
}
|
||||
let puppeteerChannel: ChromeReleaseChannel | undefined;
|
||||
if (options.devtools) {
|
||||
args.push('--auto-open-devtools-for-tabs');
|
||||
}
|
||||
if (!executablePath) {
|
||||
puppeteerChannel =
|
||||
channel && channel !== 'stable'
|
||||
? (`chrome-${channel}` as ChromeReleaseChannel)
|
||||
: 'chrome';
|
||||
}
|
||||
|
||||
if (!headless) {
|
||||
detectDisplay();
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await puppeteer.launch({
|
||||
channel: puppeteerChannel,
|
||||
targetFilter: makeTargetFilter(options.enableExtensions),
|
||||
executablePath,
|
||||
defaultViewport: null,
|
||||
userDataDir,
|
||||
pipe: true,
|
||||
headless,
|
||||
args,
|
||||
ignoreDefaultArgs: ignoreDefaultArgs,
|
||||
acceptInsecureCerts: options.acceptInsecureCerts,
|
||||
handleDevToolsAsPage: true,
|
||||
enableExtensions: options.enableExtensions,
|
||||
blocklist: options.blocklist,
|
||||
allowlist: options.allowlist,
|
||||
logger: puppeteerLogger,
|
||||
});
|
||||
if (options.logFile) {
|
||||
// FIXME: we are probably subscribing too late to catch startup logs. We
|
||||
// should expose the process earlier or expose the getRecentLogs() getter.
|
||||
browser.process()?.stderr?.pipe(options.logFile);
|
||||
browser.process()?.stdout?.pipe(options.logFile);
|
||||
}
|
||||
if (options.viewport) {
|
||||
const [page] = await browser.pages();
|
||||
await page?.resize({
|
||||
contentWidth: options.viewport.width,
|
||||
contentHeight: options.viewport.height,
|
||||
});
|
||||
}
|
||||
return browser;
|
||||
} catch (error) {
|
||||
if (
|
||||
userDataDir &&
|
||||
(error as Error).message.includes('The browser is already running')
|
||||
) {
|
||||
throw new Error(
|
||||
`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
const rootError = rootSandboxLaunchError(error as Error, args);
|
||||
if (rootError) {
|
||||
throw rootError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBrowserLaunched(
|
||||
options: McpLaunchOptions,
|
||||
): Promise<Browser> {
|
||||
if (browser?.connected) {
|
||||
return browser;
|
||||
}
|
||||
// Assign mode before browser; see the connect path above for rationale.
|
||||
const launched = await launch(options);
|
||||
browserMode = 'launched';
|
||||
browser = launched;
|
||||
return browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown hook for the active browser. Closes a launched browser (so the
|
||||
* Chrome subprocess is reaped) or disconnects from an attached browser (so
|
||||
* the user's Chrome instance stays alive). No-op if no browser is active or
|
||||
* the connection has already been dropped. Called from the server entrypoint
|
||||
* on stdin EOF / SIGTERM / SIGINT.
|
||||
*/
|
||||
export async function closeBrowser(): Promise<void> {
|
||||
const b = browser;
|
||||
const mode = browserMode;
|
||||
browser = undefined;
|
||||
browserMode = undefined;
|
||||
if (!b || !b.connected) {
|
||||
return;
|
||||
}
|
||||
if (mode === 'launched') {
|
||||
await b.close().catch(err => {
|
||||
logger?.('Failed to close browser', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await b.disconnect().catch(err => {
|
||||
logger?.('Failed to disconnect from browser', err);
|
||||
});
|
||||
}
|
||||
|
||||
export type Channel = 'stable' | 'canary' | 'beta' | 'dev';
|
||||
@@ -121,11 +121,13 @@ export const browserOptions = {
|
||||
},
|
||||
chromeArg: {
|
||||
type: 'array',
|
||||
string: true,
|
||||
describe:
|
||||
'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
|
||||
},
|
||||
ignoreDefaultChromeArg: {
|
||||
type: 'array',
|
||||
string: true,
|
||||
describe:
|
||||
'Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
|
||||
},
|
||||
|
||||
@@ -129,12 +129,14 @@ export const mcpOptions = {
|
||||
},
|
||||
blockedUrlPattern: {
|
||||
type: 'array',
|
||||
string: true,
|
||||
describe:
|
||||
"Restricts browser's network access by blocking specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Silently detaches from targets with blocked URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.",
|
||||
conflicts: ['allowedUrlPattern'],
|
||||
},
|
||||
allowedUrlPattern: {
|
||||
type: 'array',
|
||||
string: true,
|
||||
describe:
|
||||
"Restricts browser's network access by allowing only specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Requires Chrome 149+. Silently detaches from targets with unallowed URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.",
|
||||
conflicts: ['blockedUrlPattern'],
|
||||
@@ -261,6 +263,7 @@ export const mcpOptions = {
|
||||
},
|
||||
filesystemRoot: {
|
||||
type: 'array',
|
||||
string: true,
|
||||
alias: 'workspace',
|
||||
default: DEFAULT_FILESYSTEM_ROOT,
|
||||
defaultDescription: 'OS temp directory',
|
||||
|
||||
+35
-69
@@ -8,8 +8,7 @@ import type fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
|
||||
import type {Channel} from './browser.js';
|
||||
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
|
||||
import {BrowserManager} from './BrowserManager.js';
|
||||
import {type ParsedArguments} from './config/mcp-options.js';
|
||||
import {loadIssueDescriptions} from './devtools/issueDescriptions.js';
|
||||
import {McpContext} from './McpContext.js';
|
||||
@@ -41,13 +40,14 @@ puppeteer.setFollowSymlinks(false);
|
||||
const ROOTS_REQUEST_TIMEOUT = 5_000;
|
||||
|
||||
export interface McpServerOptions {
|
||||
browserManager: BrowserManager;
|
||||
logFile?: fs.WriteStream;
|
||||
}
|
||||
|
||||
export class McpServer {
|
||||
readonly server: SdkMcpServer;
|
||||
#serverArgs: ParsedArguments;
|
||||
#options: McpServerOptions;
|
||||
#browserManager: BrowserManager;
|
||||
#context?: McpContext;
|
||||
|
||||
/**
|
||||
@@ -58,12 +58,9 @@ export class McpServer {
|
||||
#lastClientRoots?: Root[];
|
||||
#toolMutex = new Mutex();
|
||||
|
||||
private constructor(
|
||||
serverArgs: ParsedArguments,
|
||||
options: McpServerOptions = {},
|
||||
) {
|
||||
private constructor(serverArgs: ParsedArguments, options: McpServerOptions) {
|
||||
this.#serverArgs = serverArgs;
|
||||
this.#options = options;
|
||||
this.#browserManager = options.browserManager;
|
||||
|
||||
if (this.#serverArgs.usageStatistics) {
|
||||
ClearcutLogger.initialize({
|
||||
@@ -125,14 +122,22 @@ export class McpServer {
|
||||
* Closes the MCP connection and disposes internal context/listeners.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.#context?.dispose();
|
||||
this.#context = undefined;
|
||||
await this.server.close();
|
||||
try {
|
||||
this.#context?.dispose();
|
||||
} catch (err) {
|
||||
logger?.('Failed to dispose context', err);
|
||||
} finally {
|
||||
this.#context = undefined;
|
||||
}
|
||||
await Promise.allSettled([
|
||||
this.#browserManager.close(),
|
||||
this.server.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
[Symbol.dispose](): void {
|
||||
this.close().catch(() => {
|
||||
// TODO: wire up the logger
|
||||
this.close().catch(err => {
|
||||
logger?.('Failed to dispose McpServer', err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,7 +147,7 @@ export class McpServer {
|
||||
|
||||
static async from(
|
||||
serverArgs: ParsedArguments,
|
||||
options: McpServerOptions = {},
|
||||
options: McpServerOptions,
|
||||
): Promise<McpServer> {
|
||||
const server = new McpServer(serverArgs, options);
|
||||
await server.#init();
|
||||
@@ -163,7 +168,7 @@ export class McpServer {
|
||||
? []
|
||||
: (this.#serverArgs.filesystemRoot ?? [])
|
||||
).map(root => {
|
||||
const rootPath = path.resolve(String(root));
|
||||
const rootPath = path.resolve(root);
|
||||
return {
|
||||
uri: pathToFileURL(rootPath).href,
|
||||
name: path.basename(rootPath) || rootPath,
|
||||
@@ -197,66 +202,19 @@ export class McpServer {
|
||||
}
|
||||
|
||||
async #getContext(): Promise<McpContext> {
|
||||
const chromeArgs: string[] = (this.#serverArgs.chromeArg ?? []).map(String);
|
||||
const ignoreDefaultChromeArgs: string[] = (
|
||||
this.#serverArgs.ignoreDefaultChromeArg ?? []
|
||||
).map(String);
|
||||
if (this.#serverArgs.proxyServer) {
|
||||
chromeArgs.push(`--proxy-server=${this.#serverArgs.proxyServer}`);
|
||||
}
|
||||
const devtools = this.#serverArgs.experimentalDevtools ?? false;
|
||||
const blocklist = this.#serverArgs.blockedUrlPattern
|
||||
? this.#serverArgs.blockedUrlPattern.map(String)
|
||||
: undefined;
|
||||
const allowlist = this.#serverArgs.allowedUrlPattern
|
||||
? this.#serverArgs.allowedUrlPattern.map(String)
|
||||
: undefined;
|
||||
|
||||
const channel = this.#serverArgs.channel as Channel | undefined;
|
||||
|
||||
const browser =
|
||||
this.#serverArgs.browserUrl ||
|
||||
this.#serverArgs.wsEndpoint ||
|
||||
this.#serverArgs.autoConnect
|
||||
? await ensureBrowserConnected({
|
||||
browserURL: this.#serverArgs.browserUrl,
|
||||
wsEndpoint: this.#serverArgs.wsEndpoint,
|
||||
wsHeaders: this.#serverArgs.wsHeaders,
|
||||
// Important: only pass channel, if autoConnect is true.
|
||||
channel: this.#serverArgs.autoConnect ? channel : undefined,
|
||||
userDataDir: this.#serverArgs.userDataDir,
|
||||
devtools,
|
||||
blocklist,
|
||||
allowlist,
|
||||
})
|
||||
: await ensureBrowserLaunched({
|
||||
headless: this.#serverArgs.headless,
|
||||
executablePath: this.#serverArgs.executablePath,
|
||||
channel,
|
||||
isolated: this.#serverArgs.isolated ?? false,
|
||||
userDataDir: this.#serverArgs.userDataDir,
|
||||
logFile: this.#options.logFile,
|
||||
viewport: this.#serverArgs.viewport,
|
||||
chromeArgs,
|
||||
ignoreDefaultChromeArgs,
|
||||
acceptInsecureCerts: this.#serverArgs.acceptInsecureCerts,
|
||||
devtools,
|
||||
enableExtensions: this.#serverArgs.categoryExtensions,
|
||||
viaCli: this.#serverArgs.viaCli,
|
||||
blocklist,
|
||||
allowlist,
|
||||
});
|
||||
const browser = await this.#browserManager.ensureBrowser();
|
||||
|
||||
if (this.#context?.browser !== browser) {
|
||||
this.#context?.dispose();
|
||||
this.#context = await McpContext.from(browser, logger, {
|
||||
experimentalDevToolsDebugging: devtools,
|
||||
experimentalDevToolsDebugging:
|
||||
this.#serverArgs.experimentalDevtools ?? false,
|
||||
experimentalIncludeAllPages:
|
||||
this.#serverArgs.experimentalIncludeAllPages,
|
||||
performanceCrux: this.#serverArgs.performanceCrux,
|
||||
sourceMaps: this.#serverArgs.sourceMaps,
|
||||
allowList: allowlist,
|
||||
blocklist: blocklist,
|
||||
allowlist: this.#serverArgs.allowedUrlPattern,
|
||||
blocklist: this.#serverArgs.blockedUrlPattern,
|
||||
allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
|
||||
// Surfaces a one-time note in the next response after a reconnect.
|
||||
reconnected: this.#context !== undefined,
|
||||
@@ -319,9 +277,17 @@ export class McpServer {
|
||||
*/
|
||||
export async function createMcpServer(
|
||||
serverArgs: ParsedArguments,
|
||||
options: McpServerOptions = {},
|
||||
options: {
|
||||
logFile?: fs.WriteStream;
|
||||
},
|
||||
): Promise<{server: SdkMcpServer}> {
|
||||
const server = await McpServer.from(serverArgs, options);
|
||||
const browserManager = new BrowserManager(serverArgs, {
|
||||
logFile: options.logFile,
|
||||
});
|
||||
const server = await McpServer.from(serverArgs, {
|
||||
browserManager,
|
||||
...options,
|
||||
});
|
||||
return {server: server.server};
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -56,6 +56,7 @@ export {
|
||||
export {default as puppeteer} from 'puppeteer-core';
|
||||
export type * from 'puppeteer-core';
|
||||
export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js';
|
||||
export {CdpBrowser} from 'puppeteer-core/internal/cdp/Browser.js';
|
||||
export {CdpFrame} from 'puppeteer-core/internal/cdp/Frame.js';
|
||||
export {CdpPage} from 'puppeteer-core/internal/cdp/Page.js';
|
||||
export {CdpExtension} from 'puppeteer-core/internal/cdp/Extension.js';
|
||||
|
||||
+328
-73
@@ -7,19 +7,18 @@
|
||||
import assert from 'node:assert';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {describe, it} from 'node:test';
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
|
||||
import {executablePath} from 'puppeteer';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {BrowserManager} from '../src/BrowserManager.js';
|
||||
import {puppeteer, type Browser} from '../src/third_party/index.js';
|
||||
|
||||
import {
|
||||
detectDisplay,
|
||||
ensureBrowserConnected,
|
||||
launch,
|
||||
makeTargetFilter,
|
||||
rootSandboxLaunchError,
|
||||
} from '../src/browser.js';
|
||||
import type {Browser} from '../src/third_party/index.js';
|
||||
|
||||
createMockParsedArguments,
|
||||
createMockPuppeteerBrowser,
|
||||
} from './mocks.js';
|
||||
import {serverHooks} from './server.js';
|
||||
|
||||
async function safeClose(browser: Browser) {
|
||||
@@ -50,7 +49,7 @@ async function runWithRetry(fn: () => Promise<void>) {
|
||||
]);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e as Error;
|
||||
lastError = e instanceof Error ? e : new Error(String(e));
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
@@ -58,8 +57,232 @@ async function runWithRetry(fn: () => Promise<void>) {
|
||||
}
|
||||
|
||||
describe('browser', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('detects display does not crash', () => {
|
||||
detectDisplay();
|
||||
BrowserManager.detectDisplay();
|
||||
});
|
||||
|
||||
describe('BrowserManager', () => {
|
||||
it('launches a browser when no connect options are set and closes it on close()', async () => {
|
||||
const pptrBrowser = createMockPuppeteerBrowser();
|
||||
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
|
||||
const connectStub = sinon.stub(puppeteer, 'connect');
|
||||
|
||||
const args = createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
channel: 'canary',
|
||||
proxyServer: 'http://localhost:8080',
|
||||
chromeArg: ['--custom-arg'],
|
||||
});
|
||||
const manager = new BrowserManager(args);
|
||||
|
||||
const browser1 = await manager.ensureBrowser();
|
||||
const browser2 = await manager.ensureBrowser();
|
||||
|
||||
assert.strictEqual(browser1, pptrBrowser);
|
||||
assert.strictEqual(browser2, pptrBrowser);
|
||||
sinon.assert.calledOnce(launchStub);
|
||||
sinon.assert.notCalled(connectStub);
|
||||
sinon.assert.calledWithMatch(launchStub, {
|
||||
channel: 'chrome-canary',
|
||||
headless: true,
|
||||
args: [
|
||||
'--custom-arg',
|
||||
'--proxy-server=http://localhost:8080',
|
||||
'--hide-crash-restore-bubble',
|
||||
'--screen-info={3840x2160}',
|
||||
],
|
||||
});
|
||||
|
||||
await manager.close();
|
||||
sinon.assert.calledOnceWithExactly(pptrBrowser.close);
|
||||
sinon.assert.notCalled(pptrBrowser.disconnect);
|
||||
});
|
||||
|
||||
it('connects to a browser when browserUrl is set and disconnects on close()', async () => {
|
||||
const pptrBrowser = createMockPuppeteerBrowser();
|
||||
const launchStub = sinon.stub(puppeteer, 'launch');
|
||||
const connectStub = sinon
|
||||
.stub(puppeteer, 'connect')
|
||||
.resolves(pptrBrowser);
|
||||
|
||||
const args = createMockParsedArguments({
|
||||
browserUrl: 'http://127.0.0.1:9222',
|
||||
channel: 'stable',
|
||||
});
|
||||
const manager = new BrowserManager(args);
|
||||
|
||||
const browser = await manager.ensureBrowser();
|
||||
|
||||
assert.strictEqual(browser, pptrBrowser);
|
||||
sinon.assert.calledOnce(connectStub);
|
||||
sinon.assert.notCalled(launchStub);
|
||||
sinon.assert.calledWithMatch(connectStub, {
|
||||
browserURL: 'http://127.0.0.1:9222',
|
||||
});
|
||||
|
||||
await manager.close();
|
||||
sinon.assert.calledOnceWithExactly(pptrBrowser.disconnect);
|
||||
sinon.assert.notCalled(pptrBrowser.close);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent ensureBrowser() calls while launch is in-flight', async () => {
|
||||
const pptrBrowser = createMockPuppeteerBrowser();
|
||||
const {promise, resolve} = Promise.withResolvers<Browser>();
|
||||
const launchStub = sinon.stub(puppeteer, 'launch').returns(promise);
|
||||
|
||||
const args = createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
});
|
||||
const manager = new BrowserManager(args);
|
||||
|
||||
const call1 = manager.ensureBrowser();
|
||||
const call2 = manager.ensureBrowser();
|
||||
|
||||
resolve(pptrBrowser);
|
||||
const [browser1, browser2] = await Promise.all([call1, call2]);
|
||||
|
||||
assert.strictEqual(browser1, pptrBrowser);
|
||||
assert.strictEqual(browser2, pptrBrowser);
|
||||
sinon.assert.calledOnce(launchStub);
|
||||
});
|
||||
|
||||
it('clears pending state on launch failure so subsequent ensureBrowser() retries', async () => {
|
||||
const pptrBrowser = createMockPuppeteerBrowser();
|
||||
const launchStub = sinon
|
||||
.stub(puppeteer, 'launch')
|
||||
.onFirstCall()
|
||||
.rejects(new Error('launch failed'))
|
||||
.onSecondCall()
|
||||
.resolves(pptrBrowser);
|
||||
|
||||
const args = createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
});
|
||||
const manager = new BrowserManager(args);
|
||||
|
||||
await assert.rejects(manager.ensureBrowser(), /launch failed/);
|
||||
const browser = await manager.ensureBrowser();
|
||||
|
||||
assert.strictEqual(browser, pptrBrowser);
|
||||
sinon.assert.calledTwice(launchStub);
|
||||
});
|
||||
|
||||
it('reconnects when existing browser is no longer connected', async () => {
|
||||
const pptrBrowser1 = createMockPuppeteerBrowser();
|
||||
const pptrBrowser2 = createMockPuppeteerBrowser();
|
||||
let isConnected = true;
|
||||
sinon.stub(pptrBrowser1, 'connected').get(() => isConnected);
|
||||
|
||||
const launchStub = sinon
|
||||
.stub(puppeteer, 'launch')
|
||||
.onFirstCall()
|
||||
.resolves(pptrBrowser1)
|
||||
.onSecondCall()
|
||||
.resolves(pptrBrowser2);
|
||||
|
||||
const args = createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
});
|
||||
const manager = new BrowserManager(args);
|
||||
|
||||
const first = await manager.ensureBrowser();
|
||||
assert.strictEqual(first, pptrBrowser1);
|
||||
|
||||
isConnected = false;
|
||||
const second = await manager.ensureBrowser();
|
||||
assert.strictEqual(second, pptrBrowser2);
|
||||
sinon.assert.calledTwice(launchStub);
|
||||
});
|
||||
|
||||
it('waits for in-flight launch, closes browser, and rejects ensureBrowser() when close() is called mid-launch', async () => {
|
||||
const pptrBrowser = createMockPuppeteerBrowser();
|
||||
const launchStarted = Promise.withResolvers<void>();
|
||||
const launchDeferred = Promise.withResolvers<Browser>();
|
||||
const closeDeferred = Promise.withResolvers<void>();
|
||||
const launchStub = sinon.stub(puppeteer, 'launch').callsFake(() => {
|
||||
launchStarted.resolve();
|
||||
return launchDeferred.promise;
|
||||
});
|
||||
let browserClosed = false;
|
||||
pptrBrowser.close.callsFake(async () => {
|
||||
await closeDeferred.promise;
|
||||
browserClosed = true;
|
||||
});
|
||||
|
||||
const args = createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
});
|
||||
const manager = new BrowserManager(args);
|
||||
|
||||
const ensurePromise1 = manager.ensureBrowser();
|
||||
await launchStarted.promise;
|
||||
|
||||
const ensurePromise2 = manager.ensureBrowser();
|
||||
const closePromise = manager.close();
|
||||
|
||||
launchDeferred.resolve(pptrBrowser);
|
||||
closeDeferred.resolve();
|
||||
|
||||
await Promise.all([
|
||||
assert.rejects(ensurePromise1, err => {
|
||||
assert.strictEqual(browserClosed, true);
|
||||
assert.match(String(err), /Browser was closed while initializing/);
|
||||
return true;
|
||||
}),
|
||||
assert.rejects(ensurePromise2, err => {
|
||||
assert.strictEqual(browserClosed, true);
|
||||
assert.match(String(err), /Browser was closed while initializing/);
|
||||
return true;
|
||||
}),
|
||||
closePromise,
|
||||
]);
|
||||
|
||||
sinon.assert.calledOnce(launchStub);
|
||||
sinon.assert.calledOnceWithExactly(pptrBrowser.close);
|
||||
});
|
||||
|
||||
it('rejects ensureBrowser() without launching a new browser when called while close() is in flight', async () => {
|
||||
const pptrBrowser = createMockPuppeteerBrowser();
|
||||
const closeStarted = Promise.withResolvers<void>();
|
||||
const closeDeferred = Promise.withResolvers<void>();
|
||||
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
|
||||
pptrBrowser.close.callsFake(() => {
|
||||
closeStarted.resolve();
|
||||
return closeDeferred.promise;
|
||||
});
|
||||
|
||||
const args = createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
});
|
||||
const manager = new BrowserManager(args);
|
||||
|
||||
const browser = await manager.ensureBrowser();
|
||||
assert.strictEqual(browser, pptrBrowser);
|
||||
|
||||
const closePromise = manager.close();
|
||||
await closeStarted.promise;
|
||||
|
||||
const ensurePromise = manager.ensureBrowser();
|
||||
closeDeferred.resolve();
|
||||
|
||||
await Promise.all([
|
||||
assert.rejects(ensurePromise, /Browser was closed while initializing/),
|
||||
closePromise,
|
||||
]);
|
||||
|
||||
sinon.assert.calledOnce(launchStub);
|
||||
sinon.assert.calledOnceWithExactly(pptrBrowser.close);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rootSandboxLaunchError', () => {
|
||||
@@ -68,7 +291,7 @@ describe('browser', () => {
|
||||
);
|
||||
|
||||
it('explains an opaque launch failure when running as root', () => {
|
||||
const error = rootSandboxLaunchError(targetClosed, [], 0);
|
||||
const error = BrowserManager.rootSandboxLaunchError(targetClosed, [], 0);
|
||||
assert.ok(error);
|
||||
assert.match(error.message, /non-root user/);
|
||||
assert.match(error.message, /pptr\.dev\/troubleshooting/);
|
||||
@@ -79,35 +302,51 @@ describe('browser', () => {
|
||||
|
||||
it('does not explain failures when not running as root', () => {
|
||||
assert.strictEqual(
|
||||
rootSandboxLaunchError(targetClosed, [], 1000),
|
||||
BrowserManager.rootSandboxLaunchError(targetClosed, [], 1000),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not explain failures on platforms without uids', () => {
|
||||
assert.strictEqual(
|
||||
rootSandboxLaunchError(targetClosed, [], undefined),
|
||||
BrowserManager.rootSandboxLaunchError(targetClosed, [], undefined),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not explain failures when the sandbox is already disabled', () => {
|
||||
assert.strictEqual(
|
||||
rootSandboxLaunchError(targetClosed, ['--no-sandbox'], 0),
|
||||
BrowserManager.rootSandboxLaunchError(
|
||||
targetClosed,
|
||||
['--no-sandbox'],
|
||||
0,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.strictEqual(
|
||||
rootSandboxLaunchError(targetClosed, ['--no-sandbox=true'], 0),
|
||||
BrowserManager.rootSandboxLaunchError(
|
||||
targetClosed,
|
||||
['--no-sandbox=true'],
|
||||
0,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('is not fooled by unrelated arguments that start the same', () => {
|
||||
assert.ok(
|
||||
rootSandboxLaunchError(targetClosed, ['--no-sandbox-and-elevated'], 0),
|
||||
BrowserManager.rootSandboxLaunchError(
|
||||
targetClosed,
|
||||
['--no-sandbox-and-elevated'],
|
||||
0,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
rootSandboxLaunchError(targetClosed, ['--disable-setuid-sandbox'], 0),
|
||||
BrowserManager.rootSandboxLaunchError(
|
||||
targetClosed,
|
||||
['--disable-setuid-sandbox'],
|
||||
0,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -119,27 +358,32 @@ describe('browser', () => {
|
||||
tmpDir,
|
||||
`temp-folder-${crypto.randomUUID()}`,
|
||||
);
|
||||
const browser1 = await launch({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
executablePath: await executablePath(),
|
||||
devtools: false,
|
||||
});
|
||||
const manager1 = new BrowserManager(
|
||||
createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
executablePath: await executablePath(),
|
||||
}),
|
||||
);
|
||||
const browser1 = await manager1.ensureBrowser();
|
||||
try {
|
||||
try {
|
||||
const browser2 = await launch({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
executablePath: await executablePath(),
|
||||
devtools: false,
|
||||
});
|
||||
const manager2 = new BrowserManager(
|
||||
createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
executablePath: await executablePath(),
|
||||
}),
|
||||
);
|
||||
const browser2 = await manager2.ensureBrowser();
|
||||
await safeClose(browser2);
|
||||
assert.fail('not reached');
|
||||
} catch (err) {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.strictEqual(
|
||||
(err as Error).message,
|
||||
err.message,
|
||||
`The browser is already running for ${folderPath}. Use --isolated to run multiple browser instances.`,
|
||||
);
|
||||
}
|
||||
@@ -156,17 +400,19 @@ describe('browser', () => {
|
||||
tmpDir,
|
||||
`temp-folder-${crypto.randomUUID()}`,
|
||||
);
|
||||
const browser = await launch({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
executablePath: await executablePath(),
|
||||
viewport: {
|
||||
width: 1501,
|
||||
height: 801,
|
||||
},
|
||||
devtools: false,
|
||||
});
|
||||
const manager = new BrowserManager(
|
||||
createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
executablePath: await executablePath(),
|
||||
viewport: {
|
||||
width: 1501,
|
||||
height: 801,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const browser = await manager.ensureBrowser();
|
||||
try {
|
||||
const [page] = await browser.pages();
|
||||
const result = await page.evaluate(() => {
|
||||
@@ -189,22 +435,27 @@ describe('browser', () => {
|
||||
tmpDir,
|
||||
`temp-folder-${crypto.randomUUID()}`,
|
||||
);
|
||||
const browser = await launch({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
executablePath: await executablePath(),
|
||||
devtools: false,
|
||||
chromeArgs: ['--remote-debugging-port=0'],
|
||||
});
|
||||
try {
|
||||
const connectedBrowser = await ensureBrowserConnected({
|
||||
const launchManager = new BrowserManager(
|
||||
createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: false,
|
||||
userDataDir: folderPath,
|
||||
devtools: false,
|
||||
});
|
||||
executablePath: await executablePath(),
|
||||
chromeArg: ['--remote-debugging-port=0'],
|
||||
}),
|
||||
);
|
||||
const browser = await launchManager.ensureBrowser();
|
||||
try {
|
||||
const manager = new BrowserManager(
|
||||
createMockParsedArguments({
|
||||
userDataDir: folderPath,
|
||||
autoConnect: true,
|
||||
}),
|
||||
);
|
||||
const connectedBrowser = await manager.ensureBrowser();
|
||||
assert.ok(connectedBrowser);
|
||||
assert.ok(connectedBrowser.connected);
|
||||
connectedBrowser.disconnect();
|
||||
await manager.close();
|
||||
} finally {
|
||||
await safeClose(browser);
|
||||
}
|
||||
@@ -225,13 +476,15 @@ describe('browser', () => {
|
||||
'<html><body>Blocked</body></html>',
|
||||
);
|
||||
|
||||
const browser = await launch({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
executablePath: await executablePath(),
|
||||
devtools: false,
|
||||
blocklist: ['*://*:*/blocked.html'],
|
||||
});
|
||||
const manager = new BrowserManager(
|
||||
createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
executablePath: await executablePath(),
|
||||
blockedUrlPattern: ['*://*:*/blocked.html'],
|
||||
}),
|
||||
);
|
||||
const browser = await manager.ensureBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
@@ -268,13 +521,15 @@ describe('browser', () => {
|
||||
'<html><body>Blocked</body></html>',
|
||||
);
|
||||
|
||||
const browser = await launch({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
executablePath: await executablePath(),
|
||||
devtools: false,
|
||||
allowlist: ['*://*:*/allowed.html'],
|
||||
});
|
||||
const manager = new BrowserManager(
|
||||
createMockParsedArguments({
|
||||
headless: true,
|
||||
isolated: true,
|
||||
executablePath: await executablePath(),
|
||||
allowedUrlPattern: ['*://*:*/allowed.html'],
|
||||
}),
|
||||
);
|
||||
const browser = await manager.ensureBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
@@ -303,8 +558,8 @@ describe('browser', () => {
|
||||
|
||||
describe('makeTargetFilter', () => {
|
||||
it('filters internal chrome and extension targets', () => {
|
||||
const filterWithoutExtensions = makeTargetFilter(false);
|
||||
const filterWithExtensions = makeTargetFilter(true);
|
||||
const filterWithoutExtensions = BrowserManager.makeTargetFilter(false);
|
||||
const filterWithExtensions = BrowserManager.makeTargetFilter(true);
|
||||
|
||||
const mockTarget = (url: string) => ({
|
||||
url: () => url,
|
||||
|
||||
@@ -38,12 +38,14 @@ import type {
|
||||
} from '../src/processors/HeapSnapshotManager.js';
|
||||
import {stableIdSymbol} from '../src/utils/id.js';
|
||||
import {
|
||||
CdpBrowser,
|
||||
CdpExtension,
|
||||
CdpFrame,
|
||||
CdpPage,
|
||||
DevTools,
|
||||
} from '../src/third_party/index.js';
|
||||
import type {
|
||||
Browser,
|
||||
Extension,
|
||||
Page,
|
||||
Result,
|
||||
@@ -116,6 +118,18 @@ export function mockListener() {
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockPuppeteerBrowser(): sinon.SinonStubbedInstance<Browser> {
|
||||
const browser = sinon.createStubInstance(
|
||||
CdpBrowser,
|
||||
) as unknown as sinon.SinonStubbedInstance<Browser>;
|
||||
sinon.stub(browser, 'connected').get(() => true);
|
||||
browser.close.resolves();
|
||||
browser.disconnect.resolves();
|
||||
browser.pages.resolves([]);
|
||||
browser.process.returns(null);
|
||||
return browser;
|
||||
}
|
||||
|
||||
export function createMockPuppeteerPage(): sinon.SinonStubbedInstance<Page> {
|
||||
const page = sinon.createStubInstance(
|
||||
CdpPage,
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ export async function withMcpContext(
|
||||
experimentalDevToolsDebugging: false,
|
||||
performanceCrux: options.performanceCrux ?? true,
|
||||
sourceMaps: options.sourceMaps ?? true,
|
||||
allowList: options.allowedUrlPattern,
|
||||
allowlist: options.allowedUrlPattern,
|
||||
blocklist: options.blockedUrlPattern,
|
||||
allowUnrestrictedPaths: options.allowUnrestrictedPaths ?? false,
|
||||
navigationTimeout:
|
||||
|
||||
Reference in New Issue
Block a user