chore: run test server per context (#874)

Fixes https://github.com/microsoft/playwright-mcp/issues/869
This commit is contained in:
Pavel Feldman
2025-08-12 13:41:08 -07:00
committed by GitHub
parent 2f41a3f6b1
commit dbd44110f1
3 changed files with 27 additions and 23 deletions

View File

@@ -21,6 +21,8 @@ import path from 'path';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
// @ts-ignore // @ts-ignore
import { registryDirectory } from 'playwright-core/lib/server/registry/index'; import { registryDirectory } from 'playwright-core/lib/server/registry/index';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { logUnhandledError, testDebug } from './log.js'; import { logUnhandledError, testDebug } from './log.js';
import { createHash } from './utils.js'; import { createHash } from './utils.js';
import { outputFile } from './config.js'; import { outputFile } from './config.js';
@@ -50,7 +52,6 @@ class BaseContextFactory implements BrowserContextFactory {
readonly description: string; readonly description: string;
readonly config: FullConfig; readonly config: FullConfig;
protected _browserPromise: Promise<playwright.Browser> | undefined; protected _browserPromise: Promise<playwright.Browser> | undefined;
protected _tracesDir: string | undefined;
constructor(name: string, description: string, config: FullConfig) { constructor(name: string, description: string, config: FullConfig) {
this.name = name; this.name = name;
@@ -58,11 +59,11 @@ class BaseContextFactory implements BrowserContextFactory {
this.config = config; this.config = config;
} }
protected async _obtainBrowser(): Promise<playwright.Browser> { protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
if (this._browserPromise) if (this._browserPromise)
return this._browserPromise; return this._browserPromise;
testDebug(`obtain browser (${this.name})`); testDebug(`obtain browser (${this.name})`);
this._browserPromise = this._doObtainBrowser(); this._browserPromise = this._doObtainBrowser(clientInfo);
void this._browserPromise.then(browser => { void this._browserPromise.then(browser => {
browser.on('disconnected', () => { browser.on('disconnected', () => {
this._browserPromise = undefined; this._browserPromise = undefined;
@@ -73,16 +74,13 @@ class BaseContextFactory implements BrowserContextFactory {
return this._browserPromise; return this._browserPromise;
} }
protected async _doObtainBrowser(): Promise<playwright.Browser> { protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> { async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this.config.saveTrace)
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
testDebug(`create browser context (${this.name})`); testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser(); const browser = await this._obtainBrowser(clientInfo);
const browserContext = await this._doCreateContext(browser); const browserContext = await this._doCreateContext(browser);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) }; return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
} }
@@ -108,11 +106,11 @@ class IsolatedContextFactory extends BaseContextFactory {
super('isolated', 'Create a new isolated browser context', config); super('isolated', 'Create a new isolated browser context', config);
} }
protected override async _doObtainBrowser(): Promise<playwright.Browser> { protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
await injectCdpPort(this.config.browser); await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName]; const browserType = playwright[this.config.browser.browserName];
return browserType.launch({ return browserType.launch({
tracesDir: this._tracesDir, tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
...this.config.browser.launchOptions, ...this.config.browser.launchOptions,
handleSIGINT: false, handleSIGINT: false,
handleSIGTERM: false, handleSIGTERM: false,
@@ -175,9 +173,7 @@ class PersistentContextFactory implements BrowserContextFactory {
await injectCdpPort(this.config.browser); await injectCdpPort(this.config.browser);
testDebug('create browser context (persistent)'); testDebug('create browser context (persistent)');
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath); const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
let tracesDir: string | undefined; const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
if (this.config.saveTrace)
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
this._userDataDirs.add(userDataDir); this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir); testDebug('lock user data dir', userDataDir);
@@ -242,3 +238,16 @@ async function findFreePort(): Promise<number> {
server.on('error', reject); server.on('error', reject);
}); });
} }
async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
if (!config.saveTrace)
return undefined;
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('\nTrace viewer listening on ' + url);
return tracesDir;
}

View File

@@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import debug from 'debug';
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { ManualPromise } from '../manualPromise.js'; import { ManualPromise } from '../manualPromise.js';
@@ -23,6 +24,8 @@ import type { ImageContent, TextContent, Tool } from '@modelcontextprotocol/sdk/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export type { Server } from '@modelcontextprotocol/sdk/server/index.js'; export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
const serverDebug = debug('pw:mcp:server');
export type ClientCapabilities = { export type ClientCapabilities = {
roots?: { roots?: {
listRoots?: boolean listRoots?: boolean
@@ -62,12 +65,14 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
}); });
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
serverDebug('listTools');
const tools = backend.tools(); const tools = backend.tools();
return { tools }; return { tools };
}); });
let heartbeatRunning = false; let heartbeatRunning = false;
server.setRequestHandler(CallToolRequestSchema, async request => { server.setRequestHandler(CallToolRequestSchema, async request => {
serverDebug('callTool', request);
await initializedPromise; await initializedPromise;
if (runHeartbeat && !heartbeatRunning) { if (runHeartbeat && !heartbeatRunning) {

View File

@@ -15,8 +15,6 @@
*/ */
import { program, Option } from 'commander'; import { program, Option } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import * as mcpTransport from './mcp/transport.js'; import * as mcpTransport from './mcp/transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js'; import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
@@ -95,14 +93,6 @@ program
serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory); serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory);
} }
await mcpTransport.start(serverBackendFactory, config.server); await mcpTransport.start(serverBackendFactory, config.server);
if (config.saveTrace) {
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('\nTrace viewer listening on ' + url);
}
}); });
function setupExitWatchdog() { function setupExitWatchdog() {