@ai-coding-labs/playwright-mcp-plus
Version:
Enhanced Playwright Tools for MCP with Project Session Isolation
298 lines (297 loc) • 13.6 kB
JavaScript
/**
* 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 fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import os from 'node:os';
import * as playwright from 'playwright';
import { logUnhandledError, testDebug } from './log.js';
import { ProjectIsolationManager } from './projectIsolation.js';
import { getInstalledExtensionPaths } from './tools/extensions.js';
/**
* Enhance launch options with MCP-managed extensions
*/
function enhanceLaunchOptionsWithExtensions(launchOptions, sessionUserDataDir) {
const mcpExtensionPaths = getInstalledExtensionPaths(sessionUserDataDir);
const enhancedOptions = { ...launchOptions };
const args = [...(enhancedOptions.args || [])];
// Collect all extension paths (existing + MCP-managed)
const allExtensionPaths = [];
// 1. Find and remove existing --load-extension arguments, extract paths
for (let i = args.length - 1; i >= 0; i--) {
const arg = args[i];
if (arg.startsWith('--load-extension=')) {
const existingPaths = arg.substring('--load-extension='.length).split(',');
allExtensionPaths.push(...existingPaths.filter(path => path.trim()));
args.splice(i, 1); // Remove existing argument
testDebug(`Found existing --load-extension argument: ${existingPaths.join(',')}`);
}
}
// 2. Add MCP-managed extension paths
allExtensionPaths.push(...mcpExtensionPaths);
// 3. Remove duplicates and empty paths
const uniqueExtensionPaths = [...new Set(allExtensionPaths.filter(path => path.trim()))];
// 4. If we have extension paths, add merged arguments
if (uniqueExtensionPaths.length > 0) {
const allExtensionPathsStr = uniqueExtensionPaths.join(',');
args.push(`--load-extension=${allExtensionPathsStr}`);
args.push(`--disable-extensions-except=${allExtensionPathsStr}`);
testDebug(`Enhanced launch options with ${uniqueExtensionPaths.length} total extensions: ${allExtensionPathsStr}`);
}
else {
testDebug('No extensions to load');
}
// 5. Ensure we don't disable extensions entirely
const disableExtensionsIndex = args.findIndex(arg => arg === '--disable-extensions');
if (disableExtensionsIndex !== -1) {
args.splice(disableExtensionsIndex, 1);
testDebug('Removed --disable-extensions argument to allow extension loading');
}
enhancedOptions.args = args;
return enhancedOptions;
}
export function contextFactory(browserConfig) {
if (browserConfig.remoteEndpoint)
return new RemoteContextFactory(browserConfig);
if (browserConfig.cdpEndpoint)
return new CdpContextFactory(browserConfig);
if (browserConfig.isolated)
return new IsolatedContextFactory(browserConfig);
return new PersistentContextFactory(browserConfig);
}
class BaseContextFactory {
browserConfig;
_browserPromise;
name;
constructor(name, browserConfig) {
this.name = name;
this.browserConfig = browserConfig;
}
async _obtainBrowser() {
if (this._browserPromise)
return this._browserPromise;
testDebug(`obtain browser (${this.name})`);
this._browserPromise = this._doObtainBrowser();
void this._browserPromise.then(browser => {
browser.on('disconnected', () => {
this._browserPromise = undefined;
});
}).catch(() => {
this._browserPromise = undefined;
});
return this._browserPromise;
}
async _doObtainBrowser() {
throw new Error('Not implemented');
}
async createContext(clientInfo, projectInfo) {
testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser();
const browserContext = await this._doCreateContext(browser, projectInfo);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser), userDataDir: undefined };
}
async _doCreateContext(browser, projectInfo) {
throw new Error('Not implemented');
}
async _closeBrowserContext(browserContext, browser) {
testDebug(`close browser context (${this.name})`);
if (browser.contexts().length === 1)
this._browserPromise = undefined;
await browserContext.close().catch(logUnhandledError);
if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`);
await browser.close().catch(logUnhandledError);
}
}
}
class IsolatedContextFactory extends BaseContextFactory {
_browserPromiseWithProject = new Map();
constructor(browserConfig) {
super('isolated', browserConfig);
}
async _obtainBrowser() {
// For isolated context, we need to defer browser creation until we have project info
// This is handled in createContext method
throw new Error('IsolatedContextFactory requires project info - use createContext directly');
}
async _doObtainBrowser() {
// This should not be called directly for IsolatedContextFactory
throw new Error('Use _doObtainBrowserWithProject instead');
}
async _doObtainBrowserWithProject(projectInfo) {
const projectKey = projectInfo ? `${projectInfo.projectDrive}:${projectInfo.projectPath}` : 'global';
if (this._browserPromiseWithProject.has(projectKey))
return this._browserPromiseWithProject.get(projectKey);
const browserPromise = this._createBrowserWithProject(projectInfo);
this._browserPromiseWithProject.set(projectKey, browserPromise);
// Handle browser disconnection
browserPromise.then(browser => {
browser.on('disconnected', () => {
this._browserPromiseWithProject.delete(projectKey);
});
}).catch(() => {
this._browserPromiseWithProject.delete(projectKey);
});
return browserPromise;
}
async _createBrowserWithProject(projectInfo) {
await injectCdpPort(this.browserConfig);
const browserType = playwright[this.browserConfig.browserName];
// Get session user data directory for extension loading
const sessionUserDataDir = this._getSessionUserDataDir(projectInfo);
const enhancedLaunchOptions = enhanceLaunchOptionsWithExtensions(this.browserConfig.launchOptions || {}, sessionUserDataDir);
return browserType.launch({
...enhancedLaunchOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).catch(error => {
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.`);
throw error;
});
}
async createContext(clientInfo, projectInfo) {
testDebug(`create browser context (${this.name})`);
const browser = await this._doObtainBrowserWithProject(projectInfo);
const browserContext = await this._doCreateContext(browser, projectInfo);
const userDataDir = this._getSessionUserDataDir(projectInfo);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser), userDataDir };
}
async _doCreateContext(browser, projectInfo) {
return browser.newContext(this.browserConfig.contextOptions);
}
_getSessionUserDataDir(projectInfo) {
if (!projectInfo?.projectDrive || !projectInfo?.projectPath)
return undefined;
try {
// Use the enhanced project isolation manager to get the session directory
return ProjectIsolationManager.createUserDataDir(projectInfo);
}
catch (error) {
// If project isolation fails, return undefined to fall back to global storage
return undefined;
}
}
}
class CdpContextFactory extends BaseContextFactory {
constructor(browserConfig) {
super('cdp', browserConfig);
}
async _doObtainBrowser() {
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint);
}
async _doCreateContext(browser, projectInfo) {
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
}
}
class RemoteContextFactory extends BaseContextFactory {
constructor(browserConfig) {
super('remote', browserConfig);
}
async _doObtainBrowser() {
const url = new URL(this.browserConfig.remoteEndpoint);
url.searchParams.set('browser', this.browserConfig.browserName);
if (this.browserConfig.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
return playwright[this.browserConfig.browserName].connect(String(url));
}
async _doCreateContext(browser, projectInfo) {
return browser.newContext();
}
}
class PersistentContextFactory {
browserConfig;
_userDataDirs = new Set();
constructor(browserConfig) {
this.browserConfig = browserConfig;
}
async createContext(clientInfo, projectInfo) {
await injectCdpPort(this.browserConfig);
testDebug('create browser context (persistent)');
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir(projectInfo);
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
const browserType = playwright[this.browserConfig.browserName];
const enhancedLaunchOptions = enhanceLaunchOptionsWithExtensions(this.browserConfig.launchOptions || {}, userDataDir);
for (let i = 0; i < 5; i++) {
try {
const browserContext = await browserType.launchPersistentContext(userDataDir, {
...enhancedLaunchOptions,
...this.browserConfig.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
});
const close = () => this._closeBrowserContext(browserContext, userDataDir);
return { browserContext, close, userDataDir };
}
catch (error) {
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;
}
}
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
}
async _closeBrowserContext(browserContext, userDataDir) {
testDebug('close browser context (persistent)');
testDebug('release user data dir', userDataDir);
await browserContext.close().catch(() => { });
this._userDataDirs.delete(userDataDir);
testDebug('close browser context complete (persistent)');
}
async _createUserDataDir(projectInfo) {
// 如果提供了项目信息,使用项目隔离管理器
if (projectInfo?.projectPath && projectInfo?.projectDrive) {
const projectUserDataDir = ProjectIsolationManager.createUserDataDir(projectInfo);
if (projectUserDataDir) {
await ProjectIsolationManager.ensureProjectDataDir(projectUserDataDir);
return projectUserDataDir;
}
}
// 否则使用默认逻辑
let cacheDirectory;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
}
async function injectCdpPort(browserConfig) {
if (browserConfig.browserName === 'chromium')
browserConfig.launchOptions.cdpPort = await findFreePort();
}
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address();
server.close(() => resolve(port));
});
server.on('error', reject);
});
}