diff --git a/packages/playwright/src/mcp/browser/browserContextFactory.ts b/packages/playwright/src/mcp/browser/browserContextFactory.ts index 2c39ba47f5917..de52f1052516a 100644 --- a/packages/playwright/src/mcp/browser/browserContextFactory.ts +++ b/packages/playwright/src/mcp/browser/browserContextFactory.ts @@ -22,6 +22,7 @@ import path from 'path'; import * as playwright from 'playwright-core'; import { registryDirectory } from 'playwright-core/lib/server/registry/index'; import { startTraceViewerServer } from 'playwright-core/lib/server'; +import { findBrowserProcess, getBrowserExecPath } from './processUtils'; import { logUnhandledError, testDebug } from '../log'; import { outputFile } from './config'; @@ -180,33 +181,33 @@ class PersistentContextFactory implements BrowserContextFactory { const browserType = playwright[this.config.browser.browserName]; for (let i = 0; i < 5; i++) { - const launchOptions: LaunchOptions = { - tracesDir, - ...this.config.browser.launchOptions, - ...this.config.browser.contextOptions, - handleSIGINT: false, - handleSIGTERM: false, - ignoreDefaultArgs: [ - '--disable-extensions', - ], - assistantMode: true, - }; - try { - const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); - const close = () => this._closeBrowserContext(browserContext, userDataDir); - return { browserContext, close }; - } catch (error: any) { - if (error.message.includes('Executable doesn\'t exist')) - throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); - if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) { - // User data directory is already in use, try again. - await new Promise(resolve => setTimeout(resolve, 1000)); - continue; - } - throw error; - } + if (!await alreadyRunning(this.config, browserType, userDataDir)) + break; + // User data directory is already in use, wait for the previous browser instance to close. + await new Promise(resolve => setTimeout(resolve, 1000)); + } + const launchOptions: LaunchOptions = { + tracesDir, + ...this.config.browser.launchOptions, + ...this.config.browser.contextOptions, + handleSIGINT: false, + handleSIGTERM: false, + ignoreDefaultArgs: [ + '--disable-extensions', + ], + assistantMode: true, + }; + try { + const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); + const close = () => this._closeBrowserContext(browserContext, userDataDir); + return { browserContext, close }; + } catch (error: any) { + if (error.message.includes('Executable doesn\'t exist')) + throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); + if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) + throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); + throw error; } - throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); } private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) { @@ -228,6 +229,13 @@ class PersistentContextFactory implements BrowserContextFactory { } } +async function alreadyRunning(config: FullConfig, browserType: playwright.BrowserType, userDataDir: string) { + const execPath = config.browser.launchOptions.executablePath ?? getBrowserExecPath(config.browser.launchOptions.channel ?? browserType.name()); + if (!execPath) + return false; + return !!findBrowserProcess(execPath, userDataDir); +} + async function injectCdpPort(browserConfig: FullConfig['browser']) { if (browserConfig.browserName === 'chromium') (browserConfig.launchOptions as any).cdpPort = await findFreePort(); diff --git a/packages/playwright/src/mcp/browser/processUtils.ts b/packages/playwright/src/mcp/browser/processUtils.ts new file mode 100644 index 0000000000000..585e728294a07 --- /dev/null +++ b/packages/playwright/src/mcp/browser/processUtils.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import childProcess from 'child_process'; +import fs from 'fs'; + +import { registry } from 'playwright-core/lib/server/registry/index'; + +export function getBrowserExecPath(channelOrName: string): string | undefined { + return registry.findExecutable(channelOrName)?.executablePath('javascript'); +} + +type CmdlinePredicate = (line: string) => boolean; + +export function findBrowserProcess(execPath: string, arg: string): string | undefined { + const predicate = (line: string) => line.includes(execPath) && line.includes(arg) && !line.includes('--type'); + try { + switch (process.platform) { + case 'darwin': + return findProcessMacos(predicate); + case 'linux': + return findProcessLinux(predicate); + case 'win32': + return findProcessWindows(execPath, arg, predicate); + default: + return undefined; + } + } catch { + return undefined; + } +} + +function findProcessLinux(predicate: CmdlinePredicate): string | undefined { + // /bin/ps is missing in slim docker images, so we read /proc fs directly. + const procDirs = fs.readdirSync('/proc').filter(name => /^\d+$/.test(name)); + for (const pid of procDirs) { + try { + const cmdlineBuffer = fs.readFileSync(`/proc/${pid}/cmdline`); + // Convert 0-separated arguments to space-separated string + const cmdline = cmdlineBuffer.toString().replace(/\0/g, ' ').trim(); + if (predicate(cmdline)) + return `${pid} ${cmdline}`; + } catch { + // Skip processes we can't read (permission denied, process died, etc.) + continue; + } + } + return undefined; +} + +function findProcessMacos(predicate: CmdlinePredicate): string | undefined { + const result = childProcess.spawnSync('/bin/ps', ['-axo', 'pid=,command=']); + if (result.status !== 0 || !result.stdout) + return undefined; + return findMatchingLine(result.stdout.toString(), predicate); +} + +function findProcessWindows(execPath: string, arg: string, predicate: CmdlinePredicate): string | undefined { + const psEscape = (path: string) => `'${path.replaceAll("'", "''")}'`; + const filter = `$_.ExecutablePath -eq ${psEscape(execPath)} -and $_.CommandLine.Contains(${psEscape(arg)}) -and $_.CommandLine -notmatch '--type'`; + const ps = childProcess.spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `Get-CimInstance Win32_Process | Where-Object { ${filter} } | Select-Object -Property ProcessId,CommandLine | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }` + ], + { encoding: 'utf8' } + ); + + if (ps.status !== 0 || !ps.stdout) + return undefined; + + return findMatchingLine(ps.stdout.toString(), predicate); +} + +function findMatchingLine(psOutput: string, predicate: CmdlinePredicate): string | undefined { + const lines = psOutput.split('\n').map(l => l.trim()).filter(Boolean); + return lines.find(predicate); +} diff --git a/tests/mcp/launch.spec.ts b/tests/mcp/launch.spec.ts index 03a637b99848f..afea8b00fb629 100644 --- a/tests/mcp/launch.spec.ts +++ b/tests/mcp/launch.spec.ts @@ -167,3 +167,46 @@ test('isolated context with storage state', async ({ startClient, server }, test pageState: expect.stringContaining(`Storage: session-value`), }); }); + +test('persistent context already running', async ({ startClient, server, mcpBrowser }, testInfo) => { + const userDataDir = testInfo.outputPath('user-data-dir'); + const { client } = await startClient({ + args: [`--user-data-dir=${userDataDir}`], + }); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const { client: client2, stderr } = await startClient({ + args: [`--user-data-dir=${userDataDir}`], + }); + const navigationPromise = client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const wait = await Promise.race([ + navigationPromise.then(() => 'done'), + new Promise(resolve => setTimeout(resolve, 1_000)).then(() => 'timeout'), + ]); + expect(wait).toBe('timeout'); + + // Check that the second client is trying to launch the browser. + await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([ + 'create context', + 'create browser context (persistent)', + 'lock user data dir' + ]); + + // Close first client's browser. + await client.callTool({ + name: 'browser_close', + arguments: { url: server.HELLO_WORLD }, + }); + + const result = await navigationPromise; + expect(result).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); +});