Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 34 additions & 26 deletions packages/playwright/src/mcp/browser/browserContextFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down
93 changes: 93 additions & 0 deletions packages/playwright/src/mcp/browser/processUtils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
43 changes: 43 additions & 0 deletions tests/mcp/launch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!`),
});
});
Loading