diff --git a/src/browser.ts b/src/browser.ts index c5a671e0..2956e74d 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -54,21 +54,22 @@ async function ensureBrowserConnected(browserURL: string) { return browser; } -async function ensureBrowserLaunched( - headless: boolean, - isolated: boolean, - customDevToolsPath?: string, - executablePath?: string, - channel?: Channel, -): Promise { - if (browser?.connected) { - return browser; - } +type McpLaunchOptions = { + executablePath?: string; + customDevTools?: string; + channel?: Channel; + userDataDir?: string; + headless: boolean; + isolated: boolean; +}; + +export async function launch(options: McpLaunchOptions): Promise { + const {channel, executablePath, customDevTools, headless, isolated} = options; const profileDirName = channel && channel !== 'stable' ? `mcp-profile-${channel}` : 'mcp-profile'; - let userDataDir: string | undefined; - if (!isolated) { + let userDataDir = options.userDataDir; + if (!isolated && !userDataDir) { userDataDir = path.join( os.homedir(), '.cache', @@ -85,8 +86,8 @@ async function ensureBrowserLaunched( '--no-first-run', '--hide-crash-restore-bubble', ]; - if (customDevToolsPath) { - args.push(`--custom-devtools-frontend=file://${customDevToolsPath}`); + if (customDevTools) { + args.push(`--custom-devtools-frontend=file://${customDevTools}`); } let puppeterChannel: ChromeReleaseChannel | undefined; if (!executablePath) { @@ -95,16 +96,45 @@ async function ensureBrowserLaunched( ? (`chrome-${channel}` as ChromeReleaseChannel) : 'chrome'; } - browser = await puppeteer.launch({ - ...connectOptions, - channel: puppeterChannel, - executablePath, - defaultViewport: null, - userDataDir, - pipe: true, - headless, - args, - }); + + try { + return await puppeteer.launch({ + ...connectOptions, + channel: puppeterChannel, + executablePath, + defaultViewport: null, + userDataDir, + pipe: true, + headless, + args, + }); + } catch (error) { + // TODO: check browser logs for `Failed to create a ProcessSingleton for + // your profile directory` instead. + if ( + userDataDir && + (error as Error).message.includes( + '(Target.setDiscoverTargets): Target closed', + ) + ) { + throw new Error( + `The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, + { + cause: error, + }, + ); + } + throw error; + } +} + +async function ensureBrowserLaunched( + options: McpLaunchOptions, +): Promise { + if (browser?.connected) { + return browser; + } + browser = await launch(options); return browser; } @@ -118,13 +148,7 @@ export async function resolveBrowser(options: { }) { const browser = options.browserUrl ? await ensureBrowserConnected(options.browserUrl) - : await ensureBrowserLaunched( - options.headless, - options.isolated, - options.customDevTools, - options.executablePath, - options.channel, - ); + : await ensureBrowserLaunched(options); return browser; } diff --git a/tests/browser.test.ts b/tests/browser.test.ts new file mode 100644 index 00000000..fd2e392a --- /dev/null +++ b/tests/browser.test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {describe, it} from 'node:test'; +import assert from 'node:assert'; +import os from 'node:os'; +import path from 'node:path'; +import {launch} from '../src/browser.js'; + +describe('browser', () => { + it('cannot launch multiple times with the same profile', async () => { + const tmpDir = os.tmpdir(); + const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); + const browser1 = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + }); + try { + try { + const browser2 = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + }); + await browser2.close(); + assert.fail('not reached'); + } catch (err) { + assert.strictEqual( + err.message, + `The browser is already running for ${folderPath}. Use --isolated to run multiple browser instances.`, + ); + } + } finally { + await browser1.close(); + } + }); +});