diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index c1e2cabc33b0b..df6c80cf115e4 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -26,6 +26,7 @@ "./lib/cli/program": "./lib/cli/program.js", "./lib/mcpBundle": "./lib/mcpBundle.js", "./lib/mcp/exports": "./lib/mcp/exports.js", + "./lib/mcp/index": "./lib/mcp/index.js", "./lib/remote/playwrightServer": "./lib/remote/playwrightServer.js", "./lib/server": "./lib/server/index.js", "./lib/server/utils/image_tools/stats": "./lib/server/utils/image_tools/stats.js", diff --git a/packages/playwright-core/src/cli/daemon/daemon.ts b/packages/playwright-core/src/cli/daemon/daemon.ts index 6b92aa55bb835..89a25504a660a 100644 --- a/packages/playwright-core/src/cli/daemon/daemon.ts +++ b/packages/playwright-core/src/cli/daemon/daemon.ts @@ -25,13 +25,14 @@ import { decorateServer } from '../../server/utils/network'; import { gracefullyProcessExitDoNotHang } from '../../server/utils/processLauncher'; import { BrowserServerBackend } from '../../mcp/browser/browserServerBackend'; +import { browserTools } from '../../mcp/browser/tools'; import { SocketConnection } from '../client/socketConnection'; import { commands } from './commands'; import { parseCommand } from './command'; import type * as mcp from '../../mcp/exports'; -import type { BrowserContextFactory } from '../../mcp/browser/browserContextFactory'; import type { FullConfig } from '../../mcp/browser/config'; +import type { BrowserContextFactory } from '../../mcp/browser/browserContextFactory'; import type { SessionConfig } from '../client/registry'; const daemonDebug = debug('pw:daemon'); @@ -75,7 +76,7 @@ export async function startMcpDaemonServer( timestamp: Date.now(), }; - const { browserContext, close } = await contextFactory.createContext(clientInfo, new AbortController().signal, {}); + const browserContext = mcpConfig.browser.isolated ? await contextFactory.createContext(clientInfo) : (await contextFactory.contexts(clientInfo))[0]; if (!noShutdown) { browserContext.on('close', () => { daemonDebug('browser closed, shutting down daemon'); @@ -83,10 +84,7 @@ export async function startMcpDaemonServer( }); } - const existingContextFactory = { - createContext: () => Promise.resolve({ browserContext, close }), - }; - const backend = new BrowserServerBackend(mcpConfig, existingContextFactory, { allTools: true }); + const backend = new BrowserServerBackend(mcpConfig, browserContext, browserTools); await backend.initialize(clientInfo); await fs.mkdir(path.dirname(socketPath), { recursive: true }); @@ -140,7 +138,7 @@ export async function startMcpDaemonServer( server.listen(socketPath, () => { daemonDebug(`daemon server listening on ${socketPath}`); resolve(async () => { - backend.serverClosed(); + await backend.dispose(); await new Promise(cb => server.close(cb)); }); }); diff --git a/packages/playwright-core/src/client/eventEmitter.ts b/packages/playwright-core/src/client/eventEmitter.ts index e1c08619ec901..cf2dc55f88107 100644 --- a/packages/playwright-core/src/client/eventEmitter.ts +++ b/packages/playwright-core/src/client/eventEmitter.ts @@ -396,3 +396,21 @@ function unwrapListeners(arr: Listener[]): Listener[] { function wrappedListener(l: Listener): Listener { return (l as any).listener; } + +export type Disposable = { + dispose: () => void; +}; + +class EventsHelper { + static addEventListener( + emitter: EventEmitterType, + eventName: (string | symbol), + handler: (...args: any[]) => void): Disposable { + emitter.on(eventName, handler); + return { + dispose: () => emitter.removeListener(eventName, handler) + }; + } +} + +export const eventsHelper = EventsHelper; diff --git a/packages/playwright-core/src/mcp/DEPS.list b/packages/playwright-core/src/mcp/DEPS.list index 198910d9b4fcc..0f8caf69d6f0d 100644 --- a/packages/playwright-core/src/mcp/DEPS.list +++ b/packages/playwright-core/src/mcp/DEPS.list @@ -2,6 +2,7 @@ ../../ ./sdk/ ./browser/ +./browser/tools/ ./extension/ ../cli/ ../utilsBundle.ts diff --git a/packages/playwright-core/src/mcp/browser/DEPS.list b/packages/playwright-core/src/mcp/browser/DEPS.list index 37a4f9d2616cf..a944a2b211da6 100644 --- a/packages/playwright-core/src/mcp/browser/DEPS.list +++ b/packages/playwright-core/src/mcp/browser/DEPS.list @@ -3,6 +3,7 @@ ./tools/ ../sdk/ ../log.ts +../../utils/ ../../utils/isomorphic/ ../../utilsBundle.ts ../../mcpBundle.ts diff --git a/packages/playwright-core/src/mcp/browser/browserContextFactory.ts b/packages/playwright-core/src/mcp/browser/browserContextFactory.ts index 5e5bf12d90235..ecfa5d4cb3238 100644 --- a/packages/playwright-core/src/mcp/browser/browserContextFactory.ts +++ b/packages/playwright-core/src/mcp/browser/browserContextFactory.ts @@ -22,7 +22,7 @@ import path from 'path'; import * as playwright from '../../..'; import { registryDirectory } from '../../server/registry/index'; import { startTraceViewerServer } from '../../server'; -import { logUnhandledError, testDebug } from '../log'; +import { testDebug } from '../log'; import { outputDir, outputFile } from './config'; import { firstRootPath } from '../sdk/server'; @@ -31,8 +31,6 @@ import type { LaunchOptions, BrowserContextOptions } from '../../client/types'; import type { ClientInfo } from '../sdk/server'; export function contextFactory(config: FullConfig): BrowserContextFactory { - if (config.sharedBrowserContext) - return SharedContextFactory.create(config); if (config.browser.remoteEndpoint) return new RemoteContextFactory(config); if (config.browser.cdpEndpoint) @@ -42,26 +40,19 @@ export function contextFactory(config: FullConfig): BrowserContextFactory { return new PersistentContextFactory(config); } -export type BrowserContextFactoryResult = { - browserContext: playwright.BrowserContext; - close: () => Promise; -}; - -type CreateContextOptions = { - toolName?: string; -}; - export interface BrowserContextFactory { - createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, options: CreateContextOptions): Promise; + contexts(clientInfo: ClientInfo): Promise; + createContext(clientInfo: ClientInfo): Promise; } export function identityBrowserContextFactory(browserContext: playwright.BrowserContext): BrowserContextFactory { return { - createContext: async (clientInfo: ClientInfo, abortSignal: AbortSignal, options: CreateContextOptions) => { - return { - browserContext, - close: async () => {} - }; + contexts: async (clientInfo: ClientInfo) => { + return [browserContext]; + }, + + createContext: async (clientInfo: ClientInfo) => { + return browserContext; } }; } @@ -76,11 +67,11 @@ class BaseContextFactory implements BrowserContextFactory { this.config = config; } - protected async _obtainBrowser(clientInfo: ClientInfo, options: CreateContextOptions): Promise { + protected async _obtainBrowser(clientInfo: ClientInfo): Promise { if (this._browserPromise) return this._browserPromise; testDebug(`obtain browser (${this._logName})`); - this._browserPromise = this._doObtainBrowser(clientInfo, options); + this._browserPromise = this._doObtainBrowser(clientInfo); void this._browserPromise.then(browser => { browser.on('disconnected', () => { this._browserPromise = undefined; @@ -91,35 +82,24 @@ class BaseContextFactory implements BrowserContextFactory { return this._browserPromise; } - protected async _doObtainBrowser(clientInfo: ClientInfo, options: CreateContextOptions): Promise { + protected async _doObtainBrowser(clientInfo: ClientInfo): Promise { throw new Error('Not implemented'); } - async createContext(clientInfo: ClientInfo, _: AbortSignal, options: CreateContextOptions): Promise { + async contexts(clientInfo: ClientInfo): Promise { + const browser = await this._obtainBrowser(clientInfo); + return browser.contexts(); + } + + async createContext(clientInfo: ClientInfo): Promise { testDebug(`create browser context (${this._logName})`); - const browser = await this._obtainBrowser(clientInfo, options); - const browserContext = await this._doCreateContext(browser, clientInfo); - await addInitScript(browserContext, this.config.browser.initScript); - return { - browserContext, - close: () => this._closeBrowserContext(browserContext, browser) - }; + const browser = await this._obtainBrowser(clientInfo); + return await this._doCreateContext(browser, clientInfo); } protected async _doCreateContext(browser: playwright.Browser, clientInfo: ClientInfo): Promise { throw new Error('Not implemented'); } - - private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) { - testDebug(`close browser context (${this._logName})`); - if (browser.contexts().length === 1) - this._browserPromise = undefined; - await browserContext.close().catch(logUnhandledError); - if (browser.contexts().length === 0) { - testDebug(`close browser (${this._logName})`); - await browser.close().catch(logUnhandledError); - } - } } class IsolatedContextFactory extends BaseContextFactory { @@ -127,7 +107,7 @@ class IsolatedContextFactory extends BaseContextFactory { super('isolated', config); } - protected override async _doObtainBrowser(clientInfo: ClientInfo, options: CreateContextOptions): Promise { + protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise { await injectCdpPort(this.config.browser); const browserType = playwright[this.config.browser.browserName]; const tracesDir = await computeTracesDir(this.config, clientInfo); @@ -185,16 +165,15 @@ class RemoteContextFactory extends BaseContextFactory { } } -class PersistentContextFactory implements BrowserContextFactory { - readonly config: FullConfig; +class PersistentContextFactory extends BaseContextFactory { readonly name = 'persistent'; readonly description = 'Create a new persistent browser context'; constructor(config: FullConfig) { - this.config = config; + super('persistent', config); } - async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, options: CreateContextOptions): Promise { + protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise { await injectCdpPort(this.config.browser); testDebug('create browser context (persistent)'); const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo); @@ -205,8 +184,6 @@ class PersistentContextFactory implements BrowserContextFactory { if (await isProfileLocked5Times(userDataDir)) throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); - testDebug('lock user data dir', userDataDir); - const browserType = playwright[this.config.browser.browserName]; const launchOptions: LaunchOptions & BrowserContextOptions = { tracesDir, @@ -221,9 +198,7 @@ class PersistentContextFactory implements BrowserContextFactory { }; try { const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); - await addInitScript(browserContext, this.config.browser.initScript); - const close = () => this._closeBrowserContext(browserContext, userDataDir); - return { browserContext, close }; + return browserContext.browser()!; } catch (error: any) { if (error.message.includes('Executable doesn\'t exist')) throwBrowserIsNotInstalledError(this.config); @@ -237,15 +212,6 @@ class PersistentContextFactory implements BrowserContextFactory { } } - private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) { - testDebug('close browser context (persistent)'); - testDebug('release user data dir', userDataDir); - await browserContext.close().catch(() => {}); - if (process.env.PWMCP_PROFILES_DIR_FOR_TEST && userDataDir.startsWith(process.env.PWMCP_PROFILES_DIR_FOR_TEST)) - await fs.promises.rm(userDataDir, { recursive: true }).catch(logUnhandledError); - testDebug('close browser context complete (persistent)'); - } - private async _createUserDataDir(clientInfo: ClientInfo) { const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory; const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName; @@ -289,59 +255,6 @@ function createHash(data: string): string { return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7); } -async function addInitScript(browserContext: playwright.BrowserContext, initScript: string[] | undefined) { - for (const scriptPath of initScript ?? []) - await browserContext.addInitScript({ path: path.resolve(scriptPath) }); -} - -export class SharedContextFactory implements BrowserContextFactory { - private _contextPromise: Promise | undefined; - private _baseFactory: BrowserContextFactory; - private static _instance: SharedContextFactory | undefined; - - static create(config: FullConfig) { - if (SharedContextFactory._instance) - throw new Error('SharedContextFactory already exists'); - const baseConfig = { ...config, sharedBrowserContext: false }; - const baseFactory = contextFactory(baseConfig); - SharedContextFactory._instance = new SharedContextFactory(baseFactory); - return SharedContextFactory._instance; - } - - private constructor(baseFactory: BrowserContextFactory) { - this._baseFactory = baseFactory; - } - - async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, options: { toolName?: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { - if (!this._contextPromise) { - testDebug('create shared browser context'); - this._contextPromise = this._baseFactory.createContext(clientInfo, abortSignal, options); - } - - const { browserContext } = await this._contextPromise; - testDebug(`shared context client connected`); - return { - browserContext, - close: async () => { - testDebug(`shared context client disconnected`); - }, - }; - } - - static async dispose() { - await SharedContextFactory._instance?._dispose(); - } - - private async _dispose() { - const contextPromise = this._contextPromise; - this._contextPromise = undefined; - if (!contextPromise) - return; - const { close } = await contextPromise; - await close(); - } -} - async function computeTracesDir(config: FullConfig, clientInfo: ClientInfo): Promise { return path.resolve(outputDir(config, clientInfo), 'traces'); } diff --git a/packages/playwright-core/src/mcp/browser/browserServerBackend.ts b/packages/playwright-core/src/mcp/browser/browserServerBackend.ts index 5a30c5a680657..a8b158eab4280 100644 --- a/packages/playwright-core/src/mcp/browser/browserServerBackend.ts +++ b/packages/playwright-core/src/mcp/browser/browserServerBackend.ts @@ -16,14 +16,13 @@ import { FullConfig } from './config'; import { Context } from './context'; -import { logUnhandledError } from '../log'; import { Response } from './response'; import { SessionLog } from './sessionLog'; -import { browserTools, filteredTools } from './tools'; import { toMcpTool } from '../sdk/tool'; +import { logUnhandledError } from '../log'; +import type * as playwright from '../../..'; import type { Tool } from './tools/tool'; -import type { BrowserContextFactory } from './browserContextFactory'; import type * as mcpServer from '../sdk/server'; import type { ServerBackend } from '../sdk/server'; @@ -32,24 +31,27 @@ export class BrowserServerBackend implements ServerBackend { private _context: Context | undefined; private _sessionLog: SessionLog | undefined; private _config: FullConfig; - private _browserContextFactory: BrowserContextFactory; + readonly browserContext: playwright.BrowserContext; - constructor(config: FullConfig, factory: BrowserContextFactory, options: { allTools?: boolean, structuredOutput?: boolean } = {}) { + constructor(config: FullConfig, browserContext: playwright.BrowserContext, tools: Tool[]) { this._config = config; - this._browserContextFactory = factory; - this._tools = options.allTools ? browserTools : filteredTools(config); + this._tools = tools; + this.browserContext = browserContext; } async initialize(clientInfo: mcpServer.ClientInfo): Promise { this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, clientInfo) : undefined; - this._context = new Context({ + this._context = new Context(this.browserContext, { config: this._config, - browserContextFactory: this._browserContextFactory, sessionLog: this._sessionLog, clientInfo, }); } + async dispose() { + await this._context?.dispose().catch(logUnhandledError); + } + async listTools(): Promise { return this._tools.map(tool => toMcpTool(tool.schema)); } @@ -82,8 +84,4 @@ export class BrowserServerBackend implements ServerBackend { } return responseObject; } - - serverClosed() { - void this._context?.dispose().catch(logUnhandledError); - } } diff --git a/packages/playwright-core/src/mcp/browser/context.ts b/packages/playwright-core/src/mcp/browser/context.ts index cc6d1de73e29b..f8e42a55af443 100644 --- a/packages/playwright-core/src/mcp/browser/context.ts +++ b/packages/playwright-core/src/mcp/browser/context.ts @@ -15,28 +15,29 @@ */ import os from 'os'; +import path from 'path'; import { fileURLToPath } from 'url'; +import { eventsHelper } from '../../client/eventEmitter'; import { debug } from '../../utilsBundle'; import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils'; import { selectors } from '../../..'; -import { logUnhandledError } from '../log'; import { Tab } from './tab'; import { outputFile, workspaceFile } from './config'; import type * as playwright from '../../..'; import type { FullConfig } from './config'; -import type { BrowserContextFactory, BrowserContextFactoryResult } from './browserContextFactory'; import type { SessionLog } from './sessionLog'; import type { Tracing } from '../../client/tracing'; +import type { Disposable } from '../../client/eventEmitter'; import type { ClientInfo } from '../sdk/server'; +import type { BrowserContext } from '../../client/browserContext'; const testDebug = debug('pw:mcp:test'); type ContextOptions = { config: FullConfig; - browserContextFactory: BrowserContextFactory; sessionLog: SessionLog | undefined; clientInfo: ClientInfo; }; @@ -64,34 +65,38 @@ export class Context { readonly config: FullConfig; readonly sessionLog: SessionLog | undefined; readonly options: ContextOptions; - private _browserContextPromise: Promise | undefined; - private _browserContextFactory: BrowserContextFactory; + private _rawBrowserContext: playwright.BrowserContext; + private _browserContextPromise: Promise | undefined; private _tabs: Tab[] = []; private _currentTab: Tab | undefined; private _clientInfo: ClientInfo; private _routes: RouteEntry[] = []; private _video: { allVideos: Set; - listener: (page: playwright.Page) => void; + params: VideoParams; } | undefined; + private _disposables: Disposable[] = []; - private static _allContexts: Set = new Set(); - private _closeBrowserContextPromise: Promise | undefined; private _runningToolName: string | undefined; - private _abortController = new AbortController(); - constructor(options: ContextOptions) { + constructor(browserContext: playwright.BrowserContext, options: ContextOptions) { this.config = options.config; this.sessionLog = options.sessionLog; this.options = options; - this._browserContextFactory = options.browserContextFactory; + this._rawBrowserContext = browserContext; this._clientInfo = options.clientInfo; testDebug('create context'); - Context._allContexts.add(this); } - static async disposeAll() { - await Promise.all([...Context._allContexts].map(context => context.dispose())); + async dispose() { + for (const disposable of this._disposables) + disposable.dispose(); + this._disposables = []; + for (const tab of this._tabs) + tab.dispose(); + this._tabs.length = 0; + this._currentTab = undefined; + this.stopVideoRecording(); } tabs(): Tab[] { @@ -109,7 +114,7 @@ export class Context { } async newTab(): Promise { - const { browserContext } = await this._ensureBrowserContext(); + const browserContext = await this.ensureBrowserContext(); const page = await browserContext.newPage(); this._currentTab = this._tabs.find(t => t.page === page)!; return this._currentTab; @@ -125,7 +130,7 @@ export class Context { } async ensureTab(): Promise { - const { browserContext } = await this._ensureBrowserContext(); + const browserContext = await this.ensureBrowserContext(); if (!this._currentTab) await browserContext.newPage(); return this._currentTab!; @@ -152,28 +157,27 @@ export class Context { async startVideoRecording(params: VideoParams) { if (this._video) throw new Error('Video recording has already been started.'); - const listener = (page: playwright.Page) => { - this._video?.allVideos.add(page.video()); - page.video().start(params).catch(() => {}); - }; - this._video = { allVideos: new Set(), listener }; + this._video = { allVideos: new Set(), params }; const browserContext = await this.ensureBrowserContext(); - browserContext.pages().forEach(listener); - browserContext.on('page', listener); + for (const page of browserContext.pages()) + await this._startPageVideo(page); } - async stopVideoRecording() { + async stopVideoRecording(): Promise { if (!this._video) - throw new Error('Video recording has not been started.'); + return []; const video = this._video; - if (this._browserContextPromise) { - const { browserContext } = await this._browserContextPromise; - browserContext.off('page', video.listener); - for (const page of browserContext.pages()) - await page.video().stop().catch(() => {}); - } + for (const page of this._rawBrowserContext.pages()) + await page.video().stop(); this._video = undefined; - return video.allVideos; + return [...video.allVideos]; + } + + private async _startPageVideo(page: playwright.Page) { + if (!this._video) + return; + this._video.allVideos.add(page.video()); + await page.video().start(this._video.params); } private _onPageCreated(page: playwright.Page) { @@ -181,6 +185,7 @@ export class Context { this._tabs.push(tab); if (!this._currentTab) this._currentTab = tab; + this._startPageVideo(page).catch(() => {}); } private _onPageClosed(tab: Tab) { @@ -191,15 +196,6 @@ export class Context { if (this._currentTab === tab) this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)]; - if (!this._tabs.length) - void this.closeBrowserContext(); - } - - async closeBrowserContext() { - if (!this._closeBrowserContextPromise) - this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError); - await this._closeBrowserContextPromise; - this._closeBrowserContextPromise = undefined; } routes(): RouteEntry[] { @@ -207,16 +203,14 @@ export class Context { } async addRoute(entry: RouteEntry): Promise { - const { browserContext } = await this._ensureBrowserContext(); + const browserContext = await this.ensureBrowserContext(); await browserContext.route(entry.pattern, entry.handler); this._routes.push(entry); } async removeRoute(pattern?: string): Promise { - if (!this._browserContextPromise) - return 0; - const { browserContext } = await this._browserContextPromise; let removed = 0; + const browserContext = await this.ensureBrowserContext(); if (pattern) { const toRemove = this._routes.filter(r => r.pattern === pattern); for (const route of toRemove) @@ -240,28 +234,6 @@ export class Context { this._runningToolName = name; } - private async _closeBrowserContextImpl() { - if (!this._browserContextPromise) - return; - - testDebug('close context'); - - const promise = this._browserContextPromise; - this._browserContextPromise = undefined; - - await promise.then(async ({ browserContext, close }) => { - if (this.config.saveTrace) - await browserContext.tracing.stop(); - await close(); - }); - } - - async dispose() { - this._abortController.abort('MCP context disposed'); - await this.closeBrowserContext(); - Context._allContexts.delete(this); - } - private async _setupRequestInterception(context: playwright.BrowserContext) { if (this.config.network?.allowedOrigins?.length) { await context.route('**', route => route.abort('blockedbyclient')); @@ -277,30 +249,16 @@ export class Context { } async ensureBrowserContext(): Promise { - const { browserContext } = await this._ensureBrowserContext(); - return browserContext; - } - - private _ensureBrowserContext() { if (this._browserContextPromise) return this._browserContextPromise; - - this._browserContextPromise = this._setupBrowserContext(); - this._browserContextPromise.catch(() => { - this._browserContextPromise = undefined; - }); + this._browserContextPromise = this._initializeBrowserContext(); return this._browserContextPromise; } - private async _setupBrowserContext(): Promise { - if (this._closeBrowserContextPromise) - throw new Error('Another browser context is being closed.'); - // TODO: move to the browser context factory to make it based on isolation mode. - + private async _initializeBrowserContext() { if (this.config.testIdAttribute) selectors.setTestIdAttribute(this.config.testIdAttribute); - const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, { toolName: this._runningToolName }); - const { browserContext } = result; + const browserContext = this._rawBrowserContext; if (!this.config.allowUnrestrictedFileAccess) { (browserContext as any)._setAllowedProtocols(['http:', 'https:', 'about:', 'data:']); (browserContext as any)._setAllowedDirectories(allRootPaths(this._clientInfo)); @@ -308,7 +266,7 @@ export class Context { await this._setupRequestInterception(browserContext); for (const page of browserContext.pages()) this._onPageCreated(page); - browserContext.on('page', page => this._onPageCreated(page)); + this._disposables.push(eventsHelper.addEventListener(browserContext as BrowserContext, 'page', page => this._onPageCreated(page))); if (this.config.saveTrace) { await (browserContext.tracing as Tracing).start({ name: 'trace-' + Date.now(), @@ -316,8 +274,16 @@ export class Context { snapshots: true, _live: true, }); + this._disposables.push({ + dispose: async () => { + await (browserContext.tracing as Tracing).stop(); + }, + }); } - return result; + const rootPath = this.firstRootPath(); + for (const initScript of this.config.browser.initScript || []) + await browserContext.addInitScript({ path: rootPath ? path.resolve(rootPath, initScript) : initScript }); + return browserContext; } lookupSecret(secretName: string): { value: string, code: string } { diff --git a/packages/playwright-core/src/mcp/browser/logFile.ts b/packages/playwright-core/src/mcp/browser/logFile.ts index 39134e4ab25ea..cc90262dd847d 100644 --- a/packages/playwright-core/src/mcp/browser/logFile.ts +++ b/packages/playwright-core/src/mcp/browser/logFile.ts @@ -52,7 +52,7 @@ export class LogFile { this._title = title; } - appendLine(wallTime: number, text: () => string | Promise) { + appendLine(wallTime: number, text: () => string) { this._writeChain = this._writeChain.then(() => this._write(wallTime, text)).catch(logUnhandledError); } @@ -87,13 +87,12 @@ export class LogFile { return chunk; } - private async _write(wallTime: number, text: () => string | Promise) { + private async _write(wallTime: number, text: () => string) { if (this._stopped) return; this._file ??= await this._context.outputFile({ prefix: this._filePrefix, ext: 'log', date: new Date(this._startTime) }, { origin: 'code' }); const relativeTime = Math.round(wallTime - this._startTime); - const renderedText = await text(); - const logLine = `[${String(relativeTime).padStart(8, ' ')}ms] ${renderedText}\n`; + const logLine = `[${String(relativeTime).padStart(8, ' ')}ms] ${text()}\n`; await fs.promises.appendFile(this._file, logLine); const lineCount = logLine.split('\n').length - 1; diff --git a/packages/playwright-core/src/mcp/browser/response.ts b/packages/playwright-core/src/mcp/browser/response.ts index 9ad348d49d401..4082e40534004 100644 --- a/packages/playwright-core/src/mcp/browser/response.ts +++ b/packages/playwright-core/src/mcp/browser/response.ts @@ -47,6 +47,7 @@ export class Response { private _context: Context; private _includeSnapshot: 'none' | 'full' | 'incremental' = 'none'; private _includeSnapshotFileName: string | undefined; + private _isClose: boolean = false; readonly toolName: string; readonly toolArgs: Record; @@ -107,6 +108,10 @@ export class Response { this._imageResults.push({ data, imageType }); } + setClose() { + this._isClose = true; + } + addError(error: string) { this._errors.push(error); } @@ -162,6 +167,7 @@ export class Response { return { content, + ...(this._isClose ? { isClose: true } : {}), ...(sections.some(section => section.isError) ? { isError: true } : {}), }; } @@ -192,6 +198,8 @@ export class Response { addSection('Open tabs', renderTabsMarkdown(tabHeaders)); addSection('Page', renderTabMarkdown(tabHeaders.find(h => h.current) ?? tabHeaders[0])); } + if (this._context.tabs().length === 0) + this._isClose = true; // Handle modal states. if (tabSnapshot?.modalStates.length) diff --git a/packages/playwright-core/src/mcp/browser/tab.ts b/packages/playwright-core/src/mcp/browser/tab.ts index 1b134438d063c..ae19819137ac8 100644 --- a/packages/playwright-core/src/mcp/browser/tab.ts +++ b/packages/playwright-core/src/mcp/browser/tab.ts @@ -21,12 +21,14 @@ import * as playwright from '../../..'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { ManualPromise } from '../../utils/isomorphic/manualPromise'; +import { eventsHelper } from '../../client/eventEmitter'; import { callOnPageNoTrace, waitForCompletion, eventWaiter } from './tools/utils'; import { logUnhandledError } from '../log'; import { LogFile } from './logFile'; import { ModalState } from './tools/tool'; import { handleDialog } from './tools/dialogs'; import { uploadFile } from './tools/files'; +import type { Disposable } from '../../client/eventEmitter'; import type { Context } from './context'; import type { Page } from '../../client/page'; import type { Locator } from '../../client/locator'; @@ -100,30 +102,34 @@ export class Tab extends EventEmitter { private _needsFullSnapshot = false; private _recentEventEntries: EventEntry[] = []; private _consoleLog: LogFile; + private _disposables: Disposable[]; constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { super(); this.context = context; this.page = page as Page; this._onPageClose = onPageClose; - page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event))); - page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error))); - page.on('request', request => this._handleRequest(request)); - page.on('response', response => this._handleResponse(response)); - page.on('requestfailed', request => this._handleRequestFailed(request)); - page.on('close', () => this._onClose()); - page.on('filechooser', chooser => { - this.setModalState({ - type: 'fileChooser', - description: 'File chooser', - fileChooser: chooser, - clearedBy: { tool: uploadFile.schema.name, skill: 'upload' } - }); - }); - page.on('dialog', dialog => this._dialogShown(dialog)); - page.on('download', download => { - void this._downloadStarted(download); - }); + const p = page as Page; + this._disposables = [ + eventsHelper.addEventListener(p, 'console', event => this._handleConsoleMessage(messageToConsoleMessage(event))), + eventsHelper.addEventListener(p, 'pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error))), + eventsHelper.addEventListener(p, 'request', request => this._handleRequest(request)), + eventsHelper.addEventListener(p, 'response', response => this._handleResponse(response)), + eventsHelper.addEventListener(p, 'requestfailed', request => this._handleRequestFailed(request)), + eventsHelper.addEventListener(p, 'close', () => this._onClose()), + eventsHelper.addEventListener(p, 'filechooser', chooser => { + this.setModalState({ + type: 'fileChooser', + description: 'File chooser', + fileChooser: chooser, + clearedBy: { tool: uploadFile.schema.name, skill: 'upload' } + }); + }), + eventsHelper.addEventListener(p, 'dialog', dialog => this._dialogShown(dialog)), + eventsHelper.addEventListener(p, 'download', download => { + void this._downloadStarted(download); + }), + ]; page.setDefaultNavigationTimeout(this.context.config.timeouts.navigation); page.setDefaultTimeout(this.context.config.timeouts.action); (page as any)[tabSymbol] = this; @@ -132,6 +138,13 @@ export class Tab extends EventEmitter { this._initializedPromise = this._initialize(); } + dispose() { + for (const disposable of this._disposables) + disposable.dispose(); + this._disposables = []; + this._consoleLog.stop(); + } + static forPage(page: playwright.Page): Tab | undefined { return (page as any)[tabSymbol]; } diff --git a/packages/playwright-core/src/mcp/browser/tools/common.ts b/packages/playwright-core/src/mcp/browser/tools/common.ts index 2cd0f05c31dc3..f1ae396a5ce78 100644 --- a/packages/playwright-core/src/mcp/browser/tools/common.ts +++ b/packages/playwright-core/src/mcp/browser/tools/common.ts @@ -30,10 +30,10 @@ const close = defineTool({ }, handle: async (context, params, response) => { - await context.closeBrowserContext(); const result = renderTabsMarkdown([]); response.addTextResult(result.join('\n')); response.addCode(`await page.close()`); + response.setClose(); }, }); diff --git a/packages/playwright-core/src/mcp/browser/tools/video.ts b/packages/playwright-core/src/mcp/browser/tools/video.ts index b289cb1c78233..02b19fd6f334f 100644 --- a/packages/playwright-core/src/mcp/browser/tools/video.ts +++ b/packages/playwright-core/src/mcp/browser/tools/video.ts @@ -55,11 +55,11 @@ const stopVideo = defineTool({ handle: async (context, params, response) => { const videos = await context.stopVideoRecording(); - if (!videos.size) { + if (!videos.length) { response.addTextResult('No videos were recorded.'); return; } - for (const [index, video] of [...videos].entries()) { + for (const [index, video] of videos.entries()) { const suffix = index ? `-${index}` : ''; let suggestedFilename = params.filename; if (suggestedFilename && suffix) { diff --git a/packages/playwright-core/src/mcp/browser/watchdog.ts b/packages/playwright-core/src/mcp/browser/watchdog.ts index a437accab7ce6..f4d28b4caa24f 100644 --- a/packages/playwright-core/src/mcp/browser/watchdog.ts +++ b/packages/playwright-core/src/mcp/browser/watchdog.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -import { SharedContextFactory } from './browserContextFactory'; -import { Context } from './context'; +import { gracefullyCloseAll, gracefullyCloseSet } from '../../utils'; +import { testDebug } from '../log'; export function setupExitWatchdog() { let isExiting = false; - const handleExit = async () => { + const handleExit = async (signal: string) => { if (isExiting) return; isExiting = true; // eslint-disable-next-line no-restricted-properties setTimeout(() => process.exit(0), 15000); - await Context.disposeAll(); - await SharedContextFactory.dispose(); + testDebug('gracefully closing ' + gracefullyCloseSet.size); + await gracefullyCloseAll(); // eslint-disable-next-line no-restricted-properties process.exit(0); }; - process.stdin.on('close', handleExit); - process.on('SIGINT', handleExit); - process.on('SIGTERM', handleExit); + process.stdin.on('close', () => handleExit('close')); + process.on('SIGINT', () => handleExit('SIGINT')); + process.on('SIGTERM', () => handleExit('SIGTERM')); } diff --git a/packages/playwright-core/src/mcp/exports.ts b/packages/playwright-core/src/mcp/exports.ts index 668439cf00809..1e9b6bb529a3a 100644 --- a/packages/playwright-core/src/mcp/exports.ts +++ b/packages/playwright-core/src/mcp/exports.ts @@ -15,12 +15,9 @@ */ // SDK -export * from './sdk/inProcessTransport'; export * from './sdk/server'; export * from './sdk/tool'; export * from './sdk/http'; - -// Browser export { browserTools } from './browser/tools'; export { BrowserServerBackend } from './browser/browserServerBackend'; export { contextFactory, identityBrowserContextFactory } from './browser/browserContextFactory'; @@ -31,12 +28,9 @@ export { setupExitWatchdog } from './browser/watchdog'; export type { BrowserContextFactory } from './browser/browserContextFactory'; export type { FullConfig } from './browser/config'; export type { Tool as BrowserTool } from './browser/tools/tool'; - -// Root export { logUnhandledError } from './log'; export type { Config, ToolCapability } from './config'; - -// CLI export { startMcpDaemonServer } from '../cli/daemon/daemon'; export { sessionConfigFromArgs } from '../cli/client/program'; export { createClientInfo } from '../cli/client/registry'; +export { filteredTools } from './browser/tools'; diff --git a/packages/playwright-core/src/mcp/extension/cdpRelay.ts b/packages/playwright-core/src/mcp/extension/cdpRelay.ts index 086eab4d83031..2dd5a064f0f8c 100644 --- a/packages/playwright-core/src/mcp/extension/cdpRelay.ts +++ b/packages/playwright-core/src/mcp/extension/cdpRelay.ts @@ -99,7 +99,7 @@ export class CDPRelayServer { return `${this._wsHost}${this._extensionPath}`; } - async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, forceNewTab: boolean) { + async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, forceNewTab: boolean) { debugLogger('Ensuring extension connection for MCP context'); if (this._extensionConnection) return; @@ -110,7 +110,6 @@ export class CDPRelayServer { new Promise((_, reject) => setTimeout(() => { reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/packages/extension/README.md for installation instructions.`)); }, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)), - new Promise((_, reject) => abortSignal.addEventListener('abort', reject)) ]); debugLogger('Extension connection established'); } diff --git a/packages/playwright-core/src/mcp/extension/extensionContextFactory.ts b/packages/playwright-core/src/mcp/extension/extensionContextFactory.ts index cd59969ee0232..741c6b051132a 100644 --- a/packages/playwright-core/src/mcp/extension/extensionContextFactory.ts +++ b/packages/playwright-core/src/mcp/extension/extensionContextFactory.ts @@ -36,35 +36,27 @@ export class ExtensionContextFactory implements BrowserContextFactory { this._executablePath = executablePath; } - async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, options: { toolName?: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { - const browser = await this._obtainBrowser(clientInfo, abortSignal, options?.toolName); - return { - browserContext: browser.contexts()[0], - close: async () => { - debugLogger('close() called for browser context'); - await browser.close(); - } - }; + async contexts(clientInfo: ClientInfo): Promise { + const browser = await this._obtainBrowser(clientInfo); + return browser.contexts(); } - private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise { - const relay = await this._startRelay(abortSignal); - const forceNewTab = toolName === 'browser_navigate'; - await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, forceNewTab); + async createContext(clientInfo: ClientInfo): Promise { + throw new Error('Creating a new context is not supported in extension mode. Please use the shared context instead.'); + } + + private async _obtainBrowser(clientInfo: ClientInfo): Promise { + const relay = await this._startRelay(); + await relay.ensureExtensionConnectionForMCPContext(clientInfo, /* forceNewTab */ false); return await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true }); } - private async _startRelay(abortSignal: AbortSignal) { + private async _startRelay() { const httpServer = createHttpServer(); // Listen to the loopback interface only. The extension will disallow // connections to other hosts anyway. await startHttpServer(httpServer, {}); - if (abortSignal.aborted) { - httpServer.close(); - throw new Error(abortSignal.reason); - } const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath); - abortSignal.addEventListener('abort', () => cdpRelayServer.stop()); debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`); return cdpRelayServer; } diff --git a/packages/playwright/src/mcp/index.ts b/packages/playwright-core/src/mcp/index.ts similarity index 51% rename from packages/playwright/src/mcp/index.ts rename to packages/playwright-core/src/mcp/index.ts index 22048d81bd263..228432f0f45d7 100644 --- a/packages/playwright/src/mcp/index.ts +++ b/packages/playwright-core/src/mcp/index.ts @@ -14,18 +14,35 @@ * limitations under the License. */ -import { BrowserServerBackend, resolveConfig, contextFactory, createServer } from 'playwright-core/lib/mcp/exports'; +import { resolveConfig } from './browser/config'; +import { filteredTools } from './browser/tools'; +import { contextFactory } from './browser/browserContextFactory'; +import { BrowserServerBackend } from './browser/browserServerBackend'; +import { createServer } from './sdk/server'; -import type { Config, BrowserContextFactory } from 'playwright-core/lib/mcp/exports'; +import type { BrowserContextFactory } from './browser/browserContextFactory'; import type { BrowserContext } from 'playwright'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { ClientInfo, ServerBackendFactory } from './sdk/server'; +import type { Config } from './config'; const packageJSON = require('../../package.json'); export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise { const config = await resolveConfig(userConfig); - const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config); - return createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false); + const tools = filteredTools(config); + const backendFactory: ServerBackendFactory = { + name: 'api', + nameInConfig: 'api', + version: packageJSON.version, + toolSchemas: tools.map(tool => tool.schema), + create: async (clientInfo: ClientInfo) => { + const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config); + return new BrowserServerBackend(config, await factory.createContext(clientInfo), tools); + }, + disposed: async () => { } + }; + return createServer('api', packageJSON.version, backendFactory, false); } class SimpleBrowserContextFactory implements BrowserContextFactory { @@ -38,11 +55,12 @@ class SimpleBrowserContextFactory implements BrowserContextFactory { this._contextGetter = contextGetter; } - async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise }> { + async contexts(): Promise { const browserContext = await this._contextGetter(); - return { - browserContext, - close: () => browserContext.close() - }; + return [browserContext]; + } + + async createContext(): Promise { + throw new Error('Creating a new context is not supported in SimpleBrowserContextFactory.'); } } diff --git a/packages/playwright-core/src/mcp/program.ts b/packages/playwright-core/src/mcp/program.ts index 5ece0578b791a..d043d50d005a4 100644 --- a/packages/playwright-core/src/mcp/program.ts +++ b/packages/playwright-core/src/mcp/program.ts @@ -22,8 +22,11 @@ import { setupExitWatchdog } from './browser/watchdog'; import { contextFactory } from './browser/browserContextFactory'; import { BrowserServerBackend } from './browser/browserServerBackend'; import { ExtensionContextFactory } from './extension/extensionContextFactory'; +import { filteredTools } from './browser/tools'; +import { testDebug } from './log'; import type { Command } from '../utilsBundle'; +import type { ClientInfo } from './sdk/server'; export function decorateMCPCommand(command: Command, version: string) { command @@ -89,25 +92,50 @@ export function decorateMCPCommand(command: Command, version: string) { options.caps.push('devtools'); const config = await resolveCLIConfig(options); - const browserContextFactory = contextFactory(config); - const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath); - + const tools = filteredTools(config); if (config.extension) { const serverBackendFactory: mcpServer.ServerBackendFactory = { name: 'Playwright w/ extension', nameInConfig: 'playwright-extension', version, - create: () => new BrowserServerBackend(config, extensionContextFactory) + toolSchemas: tools.map(tool => tool.schema), + create: async (clientInfo: ClientInfo) => { + const extensionContextFactory = new ExtensionContextFactory( + config.browser.launchOptions.channel || 'chrome', + config.browser.userDataDir, + config.browser.launchOptions.executablePath); + const browserContext = await extensionContextFactory.createContext(clientInfo); + return new BrowserServerBackend(config, browserContext, tools); + }, + disposed: async () => { } }; await mcpServer.start(serverBackendFactory, config.server); return; } + const sharedContextFactory = config.sharedBrowserContext ? contextFactory(config) : undefined; + let clientCount = 0; const factory: mcpServer.ServerBackendFactory = { name: 'Playwright', nameInConfig: 'playwright', version, - create: () => new BrowserServerBackend(config, browserContextFactory) + toolSchemas: tools.map(tool => tool.schema), + create: async (clientInfo: ClientInfo) => { + clientCount++; + const browserContextFactory = sharedContextFactory || contextFactory(config); + const browserContext = config.browser.isolated ? await browserContextFactory.createContext(clientInfo) : (await browserContextFactory.contexts(clientInfo))[0]; + return new BrowserServerBackend(config, browserContext, tools); + }, + disposed: async backend => { + clientCount--; + if (sharedContextFactory && clientCount > 0) + return; + + testDebug('close browser'); + const browserContext = (backend as BrowserServerBackend).browserContext; + await browserContext.close().catch(() => { }); + await browserContext.browser()!.close().catch(() => { }); + } }; await mcpServer.start(factory, config.server); }); diff --git a/packages/playwright-core/src/mcp/sdk/http.ts b/packages/playwright-core/src/mcp/sdk/http.ts index 49d3cfa40227d..8c94a8a594c7a 100644 --- a/packages/playwright-core/src/mcp/sdk/http.ts +++ b/packages/playwright-core/src/mcp/sdk/http.ts @@ -113,10 +113,10 @@ async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.I } else if (req.method === 'GET') { const transport = new mcpBundle.SSEServerTransport('/sse', res); sessions.set(transport.sessionId, transport); - testDebug(`create SSE session: ${transport.sessionId}`); + testDebug(`create SSE session`); await mcpServer.connect(serverBackendFactory, transport, false); res.on('close', () => { - testDebug(`delete SSE session: ${transport.sessionId}`); + testDebug(`delete SSE session`); sessions.delete(transport.sessionId); }); return; @@ -142,7 +142,7 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: const transport = new mcpBundle.StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: async sessionId => { - testDebug(`create http session: ${transport.sessionId}`); + testDebug(`create http session`); await mcpServer.connect(serverBackendFactory, transport, true); sessions.set(sessionId, transport); } @@ -152,7 +152,7 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: if (!transport.sessionId) return; sessions.delete(transport.sessionId); - testDebug(`delete http session: ${transport.sessionId}`); + testDebug(`delete http session`); }; await transport.handleRequest(req, res); diff --git a/packages/playwright-core/src/mcp/sdk/inProcessTransport.ts b/packages/playwright-core/src/mcp/sdk/inProcessTransport.ts deleted file mode 100644 index 317cff11003cc..0000000000000 --- a/packages/playwright-core/src/mcp/sdk/inProcessTransport.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * 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 type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js'; -import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js'; - -export class InProcessTransport implements Transport { - private _server: Server; - private _serverTransport: InProcessServerTransport; - private _connected: boolean = false; - - constructor(server: Server) { - this._server = server; - this._serverTransport = new InProcessServerTransport(this); - } - - async start(): Promise { - if (this._connected) - throw new Error('InprocessTransport already started!'); - - await this._server.connect(this._serverTransport); - this._connected = true; - } - - async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { - if (!this._connected) - throw new Error('Transport not connected'); - - - this._serverTransport._receiveFromClient(message); - } - - async close(): Promise { - if (this._connected) { - this._connected = false; - this.onclose?.(); - this._serverTransport.onclose?.(); - } - } - - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - - _receiveFromServer(message: JSONRPCMessage, extra?: MessageExtraInfo): void { - this.onmessage?.(message, extra); - } -} - -class InProcessServerTransport implements Transport { - private _clientTransport: InProcessTransport; - - constructor(clientTransport: InProcessTransport) { - this._clientTransport = clientTransport; - } - - async start(): Promise { - } - - async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { - this._clientTransport._receiveFromServer(message); - } - - async close(): Promise { - this.onclose?.(); - } - - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - _receiveFromClient(message: JSONRPCMessage): void { - this.onmessage?.(message); - } -} diff --git a/packages/playwright-core/src/mcp/sdk/server.ts b/packages/playwright-core/src/mcp/sdk/server.ts index 80332aecddd4a..d176d29fa06b1 100644 --- a/packages/playwright-core/src/mcp/sdk/server.ts +++ b/packages/playwright-core/src/mcp/sdk/server.ts @@ -20,14 +20,14 @@ import { debug } from '../../utilsBundle'; import * as mcpBundle from '../../mcpBundle'; import { startMcpHttpServer } from './http'; -import { InProcessTransport } from './inProcessTransport'; +import { toMcpTool } from './tool'; -import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; export type { Server } from '@modelcontextprotocol/sdk/server/index.js'; export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { ToolSchema } from './tool'; const serverDebug = debug('pw:mcp:server'); const serverDebugResponse = debug('pw:mcp:server:response'); @@ -42,40 +42,49 @@ export type ClientInfo = { export type ProgressParams = { message?: string, progress?: number, total?: number }; export type ProgressCallback = (params: ProgressParams) => void; +class BackendManager { + private _backends = new Map(); + + async createBackend(factory: ServerBackendFactory, clientInfo: ClientInfo): Promise { + const backend = await factory.create(clientInfo); + await backend.initialize?.(clientInfo); + this._backends.set(backend, factory); + return backend; + } + + async disposeBackend(backend: ServerBackend) { + const factory = this._backends.get(backend); + if (!factory) + return; + await backend.dispose?.(); + await factory.disposed(backend).catch(serverDebug); + this._backends.delete(backend); + } +} + +const backendManager = new BackendManager(); + export interface ServerBackend { initialize?(clientInfo: ClientInfo): Promise; - listTools(): Promise; - callTool(name: string, args: CallToolRequest['params']['arguments'], progress: ProgressCallback): Promise; - serverClosed?(server: Server): void; + callTool(name: string, args: CallToolRequest['params']['arguments'], progress: ProgressCallback): Promise; + dispose?(): Promise; } export type ServerBackendFactory = { name: string; nameInConfig: string; version: string; - create: () => ServerBackend; + toolSchemas: ToolSchema[]; + create: (clientInfo: ClientInfo) => Promise; + disposed: (backend: ServerBackend) => Promise; }; export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) { - const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat); + const server = createServer(factory.name, factory.version, factory, runHeartbeat); await server.connect(transport); } -export function wrapInProcess(backend: ServerBackend): Transport { - const server = createServer('Internal', '0.0.0', backend, false); - return new InProcessTransport(server); -} - -export async function wrapInClient(backend: ServerBackend, options: { name: string, version: string }): Promise { - const server = createServer('Internal', '0.0.0', backend, false); - const transport = new InProcessTransport(server); - const client = new mcpBundle.Client({ name: options.name, version: options.version }); - await client.connect(transport); - await client.ping(); - return client; -} - -export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server { +export function createServer(name: string, version: string, factory: ServerBackendFactory, runHeartbeat: boolean): Server { const server = new mcpBundle.Server({ name, version }, { capabilities: { tools: {}, @@ -84,11 +93,14 @@ export function createServer(name: string, version: string, backend: ServerBacke server.setRequestHandler(mcpBundle.ListToolsRequestSchema, async () => { serverDebug('listTools'); - const tools = await backend.listTools(); - return { tools }; + return { tools: factory.toolSchemas.map(s => toMcpTool(s)) }; }); - let initializePromise: Promise | undefined; + let backendPromise: Promise | undefined; + + const onClose = () => backendPromise?.then(b => backendManager.disposeBackend(b)).catch(serverDebug); + addServerListener(server, 'close', onClose); + server.setRequestHandler(mcpBundle.CallToolRequestSchema, async (request, extra) => { serverDebug('callTool', request); @@ -108,25 +120,35 @@ export function createServer(name: string, version: string, backend: ServerBacke } : () => {}; try { - if (!initializePromise) - initializePromise = initializeServer(server, backend, runHeartbeat); - await initializePromise; + if (!backendPromise) { + backendPromise = initializeServer(server, factory, runHeartbeat).catch(e => { + backendPromise = undefined; + throw e; + }); + } + + const backend = await backendPromise; const toolResult = await backend.callTool(request.params.name, request.params.arguments || {}, progress); + if (toolResult.isClose) { + await backendManager.disposeBackend(backend).catch(serverDebug); + backendPromise = undefined; + delete toolResult.isClose; + } + const mergedResult = mergeTextParts(toolResult); serverDebugResponse('callResult', mergedResult); return mergedResult; } catch (error) { return { - content: [{ type: 'text', text: '### Result\n' + String(error) }], + content: [{ type: 'text', text: '### Error\n' + String(error) }], isError: true, }; } }); - addServerListener(server, 'close', () => backend.serverClosed?.(server)); return server; } -const initializeServer = async (server: Server, backend: ServerBackend, runHeartbeat: boolean) => { +const initializeServer = async (server: Server, factory: ServerBackendFactory, runHeartbeat: boolean): Promise => { const capabilities = server.getClientCapabilities(); let clientRoots: Root[] = []; if (capabilities?.roots) { @@ -144,9 +166,10 @@ const initializeServer = async (server: Server, backend: ServerBackend, runHeart timestamp: Date.now(), }; - await backend.initialize?.(clientInfo); + const backend = await backendManager.createBackend(factory, clientInfo); if (runHeartbeat) startHeartbeat(server); + return backend; }; const startHeartbeat = (server: Server) => { diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index a926f78f33600..7368f493e41ed 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -46,7 +46,9 @@ export function createCustomMessageHandler(testInfo: TestInfoImpl, context: play if (data.initialize) { if (backend) throw new Error('MCP backend is already initialized'); - backend = new BrowserServerBackend({ ...defaultConfig, capabilities: ['testing'] }, identityBrowserContextFactory(context)); + const config: mcp.FullConfig = { ...defaultConfig, capabilities: ['testing'] }; + const tools = mcp.filteredTools(config); + backend = new BrowserServerBackend(config, context, tools); await backend.initialize(data.initialize.clientInfo); const pausedMessage = await generatePausedMessage(testInfo, context); return { initialize: { pausedMessage } }; @@ -65,7 +67,7 @@ export function createCustomMessageHandler(testInfo: TestInfoImpl, context: play } if (data.close) { - backend?.serverClosed(); + await backend?.dispose(); backend = undefined; return { close: {} }; } diff --git a/packages/playwright/src/mcp/test/testBackend.ts b/packages/playwright/src/mcp/test/testBackend.ts index 6b5c3b5e29206..40be367af5e3d 100644 --- a/packages/playwright/src/mcp/test/testBackend.ts +++ b/packages/playwright/src/mcp/test/testBackend.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import EventEmitter from 'events'; + import { z as zod } from 'playwright-core/lib/mcpBundle'; import * as mcp from 'playwright-core/lib/mcp/exports'; import { browserTools } from 'playwright-core/lib/mcp/exports'; @@ -26,26 +28,30 @@ import * as plannerTools from './plannerTools.js'; import type { TestTool } from './testTool'; import type { BrowserTool } from 'playwright-core/lib/mcp/exports'; -export class TestServerBackend implements mcp.ServerBackend { +const typesWithIntent = ['action', 'assertion', 'input']; + +export const testServerBackendTools: TestTool[] = [ + plannerTools.saveTestPlan, + plannerTools.setupPage, + plannerTools.submitTestPlan, + generatorTools.setupPage, + generatorTools.generatorReadLog, + generatorTools.generatorWriteTest, + testTools.listTests, + testTools.runTests, + testTools.debugTest, + ...browserTools.map(tool => wrapBrowserTool(tool)), +]; + +export class TestServerBackend extends EventEmitter implements mcp.ServerBackend { readonly name = 'Playwright'; readonly version = '0.0.1'; - private _tools: TestTool[] = [ - plannerTools.saveTestPlan, - plannerTools.setupPage, - plannerTools.submitTestPlan, - generatorTools.setupPage, - generatorTools.generatorReadLog, - generatorTools.generatorWriteTest, - testTools.listTests, - testTools.runTests, - testTools.debugTest, - ...browserTools.map(tool => wrapBrowserTool(tool)), - ]; private _options: { muteConsole?: boolean, headless?: boolean }; private _context: TestContext | undefined; private _configPath: string | undefined; constructor(configPath: string | undefined, options?: { muteConsole?: boolean, headless?: boolean }) { + super(); this._options = options || {}; this._configPath = configPath; } @@ -54,14 +60,10 @@ export class TestServerBackend implements mcp.ServerBackend { this._context = new TestContext(clientInfo, this._configPath, this._options); } - async listTools(): Promise { - return this._tools.map(tool => mcp.toMcpTool(tool.schema)); - } - async callTool(name: string, args: mcp.CallToolRequest['params']['arguments']): Promise { - const tool = this._tools.find(tool => tool.schema.name === name); + const tool = testServerBackendTools.find(tool => tool.schema.name === name); if (!tool) - throw new Error(`Tool not found: ${name}. Available tools: ${this._tools.map(tool => tool.schema.name).join(', ')}`); + throw new Error(`Tool not found: ${name}. Available tools: ${testServerBackendTools.map(tool => tool.schema.name).join(', ')}`); try { return await tool.handle(this._context!, tool.schema.inputSchema.parse(args || {})); } catch (e) { @@ -69,13 +71,11 @@ export class TestServerBackend implements mcp.ServerBackend { } } - serverClosed() { - void this._context?.close(); + async dispose() { + await this._context?.close(); } } -const typesWithIntent = ['action', 'assertion', 'input']; - function wrapBrowserTool(tool: BrowserTool): TestTool { const inputSchema = typesWithIntent.includes(tool.schema.type) ? (tool.schema.inputSchema as any).extend({ intent: zod.string().describe('The intent of the call, for example the test step description plan idea') diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 6d44f5c48dff5..a5947e6967992 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -35,7 +35,7 @@ import * as testServer from './runner/testServer'; import { runWatchModeLoop } from './runner/watchMode'; import { runAllTestsWithConfig, TestRunner } from './runner/testRunner'; import { createErrorCollectingReporter } from './runner/reporters'; -import { TestServerBackend } from './mcp/test/testBackend'; +import { TestServerBackend, testServerBackendTools } from './mcp/test/testBackend'; import { ClaudeGenerator, OpencodeGenerator, VSCodeGenerator, CopilotGenerator } from './agents/generateAgents'; import type { ConfigCLIOverrides } from './common/ipc'; @@ -159,7 +159,9 @@ function addTestMCPServerCommand(program: Command) { name: 'Playwright Test Runner', nameInConfig: 'playwright-test-runner', version: packageJSON.version, - create: () => new TestServerBackend(options.config, { muteConsole: options.port === undefined, headless: options.headless }), + toolSchemas: testServerBackendTools.map(tool => tool.schema), + create: async () => new TestServerBackend(options.config, { muteConsole: options.port === undefined, headless: options.headless }), + disposed: async () => { } }; // TODO: add all options from mcp.startHttpServer. await mcp.start(factory, { port: options.port === undefined ? undefined : +options.port, host: options.host }); diff --git a/tests/mcp/fixtures.ts b/tests/mcp/fixtures.ts index e023a8418a370..7429ada6c0989 100644 --- a/tests/mcp/fixtures.ts +++ b/tests/mcp/fixtures.ts @@ -292,11 +292,7 @@ export const expect = baseExpect.extend({ }, }); -export function formatOutput(output: string): string[] { - return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean); -} - -export const mcpServerPath = [path.join(__dirname, '../../packages/playwright/cli.js'), 'run-mcp-server']; +export const mcpServerPath = [path.join(__dirname, '../../packages/playwright-core/cli.js'), 'run-mcp-server']; export const testMcpServerPath = [path.join(__dirname, '../../packages/playwright-test/cli.js'), 'run-test-mcp-server']; type Files = { [key: string]: string | Buffer }; @@ -347,3 +343,11 @@ export async function prepareDebugTest(startClient: StartClient, testFile?: stri const [, id] = listResult.content[0].text.match(/\[id=([^\]]+)\]/); return { client, id }; } + +export function formatLog(stderr: string) { + const lines = stderr.split('\n').filter(l => l.startsWith('pw:mcp:test')).map(l => l.replace(/^pw:mcp:test\s+/, '')); + const object = {}; + for (const line of lines) + object[line] = (object[line] || 0) + 1; + return object; +} diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index f19d19078756d..3ab95d70fdabd 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -21,7 +21,7 @@ import dns from 'dns'; import { ChildProcess, spawn } from 'child_process'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { test as baseTest, expect, mcpServerPath } from './fixtures'; +import { test as baseTest, expect, mcpServerPath, formatLog } from './fixtures'; import type { Config } from '../../packages/playwright-core/src/mcp/config'; import { ListRootsRequestSchema } from 'playwright-core/lib/mcpBundle'; @@ -48,6 +48,7 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1', }, + cwd: testInfo.outputPath(), }); let stderr = ''; const url = await new Promise(resolve => cp!.stderr?.on('data', data => { @@ -130,20 +131,36 @@ test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, ser await transport2.terminateSession(); await client2.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create http session/)).length).toBe(2); - expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create http session': 2, + 'delete http session': 2, + 'create browser context \(isolated\)': 2, + 'create context': 2, + 'obtain browser \(isolated\)': 2, + 'close browser': 2, + }); +}); + +test('http transport browser sigint', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint({ args: ['--isolated'] }); - expect(lines.filter(line => line.match(/create context/)).length).toBe(2); - expect(lines.filter(line => line.match(/close context/)).length).toBe(2); + const transport = new StreamableHTTPClientTransport(new URL('/mcp', url)); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(transport); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); - expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2); - expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2); + await fetch(new URL('/killkillkill', url).href).catch(() => {}); - expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2); - expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2); - }).toPass(); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create browser context (isolated)': 1, + 'create context': 1, + 'create http session': 1, + 'obtain browser (isolated)': 1, + 'gracefully closing 1': 1, + }); }); test('http transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => { @@ -180,20 +197,14 @@ test('http transport browser lifecycle (isolated, multiclient)', async ({ server await transport3.terminateSession(); await client3.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create http session/)).length).toBe(3); - expect(lines.filter(line => line.match(/delete http session/)).length).toBe(3); - - expect(lines.filter(line => line.match(/create context/)).length).toBe(3); - expect(lines.filter(line => line.match(/close context/)).length).toBe(3); - - expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3); - expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3); - - expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1); - expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1); - }).toPass(); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create http session': 3, + 'delete http session': 3, + 'create context': 3, + 'create browser context (isolated)': 3, + 'obtain browser (isolated)': 3, + 'close browser': 3, + }); }); test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => { @@ -219,20 +230,14 @@ test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, s await transport2.terminateSession(); await client2.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create http session/)).length).toBe(2); - expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2); - - expect(lines.filter(line => line.match(/create context/)).length).toBe(2); - expect(lines.filter(line => line.match(/close context/)).length).toBe(2); - - expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2); - expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2); - - expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2); - expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2); - }).toPass(); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create http session': 2, + 'delete http session': 2, + 'create context': 2, + 'close browser': 2, + 'obtain browser (persistent)': 2, + 'create browser context (persistent)': 2, + }); }); test('http transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { @@ -298,33 +303,14 @@ test('http transport shared context', async ({ serverEndpoint, server }) => { await transport2.terminateSession(); await client2.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create http session/)).length).toBe(2); - expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2); - - // Should have only one context creation since it's shared - expect(lines.filter(line => line.match(/create shared browser context/)).length).toBe(1); - - // Should see client connect/disconnect messages - expect(lines.filter(line => line.match(/shared context client connected/)).length).toBe(2); - expect(lines.filter(line => line.match(/shared context client disconnected/)).length).toBe(2); - expect(lines.filter(line => line.match(/create context/)).length).toBe(2); - expect(lines.filter(line => line.match(/close context/)).length).toBe(2); - - // Context should only close when the server shuts down. - expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(0); - }).toPass(); - - // Simulate Ctrl+C in a way that works on Windows too. - await fetch(new URL('/killkillkill', url).href).catch(() => {}); - - await expect(async () => { - const lines = stderr().split('\n'); - // Context should only close when the server shuts down. - expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(1); - }).toPass(); - + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create browser context (persistent)': 1, + 'create http session': 2, + 'delete http session': 2, + 'obtain browser (persistent)': 1, + 'create context': 2, + 'close browser': 1, + }); }); test('http transport (default)', async ({ serverEndpoint }) => { @@ -365,10 +351,9 @@ test('client should receive list roots request', async ({ serverEndpoint, server }); test('should not allow rebinding to localhost', async ({ serverEndpoint }) => { - const { url, stderr } = await serverEndpoint(); + const { url } = await serverEndpoint(); const ip = await resolveToIp('localhost'); const response = await fetch(url.href.replace('localhost', ip)); - console.log('logs:', stderr()); expect.soft(response.status).toBe(403); expect.soft(await response.text()).toContain('Access is only allowed at localhost'); }); diff --git a/tests/mcp/launch.spec.ts b/tests/mcp/launch.spec.ts index 67d90077abba3..063297f101f20 100644 --- a/tests/mcp/launch.spec.ts +++ b/tests/mcp/launch.spec.ts @@ -16,7 +16,7 @@ import fs from 'fs'; -import { test, expect, formatOutput } from './fixtures'; +import { test, expect, formatLog } from './fixtures'; test('test reopen browser', async ({ startClient, server }) => { const { client, stderr } = await startClient({ @@ -42,26 +42,12 @@ test('test reopen browser', async ({ startClient, server }) => { snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); - await client.close(); - - if (process.platform === 'win32') - return; - - await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([ - 'create context', - 'create browser context (persistent)', - 'lock user data dir', - 'close context', - 'close browser context (persistent)', - 'release user data dir', - 'close browser context complete (persistent)', - 'create browser context (persistent)', - 'lock user data dir', - 'close context', - 'close browser context (persistent)', - 'release user data dir', - 'close browser context complete (persistent)', - ]); + await expect.poll(() => formatLog(stderr()), { timeout: 0 }).toEqual({ + 'obtain browser (persistent)': 2, + 'create context': 2, + 'create browser context (persistent)': 2, + 'close browser': 1, + }); }); test('executable path', async ({ startClient, server }) => { diff --git a/tests/mcp/library.spec.ts b/tests/mcp/library.spec.ts index 24eaf2d05e31a..7265043a1083f 100644 --- a/tests/mcp/library.spec.ts +++ b/tests/mcp/library.spec.ts @@ -20,7 +20,7 @@ import { test, expect } from './fixtures'; test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => { const file = testInfo.outputPath('main.cjs'); await fs.writeFile(file, ` - import('playwright/lib/mcp/index') + import('playwright-core/lib/mcp/index') .then(playwrightMCP => playwrightMCP.createConnection()) .then(() => console.log('OK')); `); diff --git a/tests/mcp/sse.spec.ts b/tests/mcp/sse.spec.ts index e0f16cfd345fb..ad0e390a95fe2 100644 --- a/tests/mcp/sse.spec.ts +++ b/tests/mcp/sse.spec.ts @@ -19,7 +19,7 @@ import fs from 'fs'; import { ChildProcess, spawn } from 'child_process'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { test as baseTest, expect, mcpServerPath } from './fixtures'; +import { test as baseTest, expect, mcpServerPath, formatLog } from './fixtures'; import type { Config } from '../../packages/playwright-core/src/mcp/config'; @@ -45,6 +45,7 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1', }, + cwd: testInfo.outputPath(), }); let stderr = ''; const url = await new Promise(resolve => cp!.stderr?.on('data', data => { @@ -105,20 +106,14 @@ test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, serv }); await client2.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); - expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); - - expect(lines.filter(line => line.match(/create context/)).length).toBe(2); - expect(lines.filter(line => line.match(/close context/)).length).toBe(2); - - expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2); - expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2); - - expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2); - expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2); - }).toPass(); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create SSE session': 2, + 'delete SSE session': 2, + 'create context': 2, + 'create browser context (isolated)': 2, + 'obtain browser (isolated)': 2, + 'close browser': 2, + }); }); test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => { @@ -152,20 +147,14 @@ test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverE await client2.close(); await client3.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3); - expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3); - - expect(lines.filter(line => line.match(/create context/)).length).toBe(3); - expect(lines.filter(line => line.match(/close context/)).length).toBe(3); - - expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3); - expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3); - - expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1); - expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1); - }).toPass(); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create SSE session': 3, + 'delete SSE session': 3, + 'create context': 3, + 'obtain browser (isolated)': 3, + 'create browser context (isolated)': 3, + 'close browser': 3, + }); }); test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => { @@ -189,20 +178,14 @@ test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, se }); await client2.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); - expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); - - expect(lines.filter(line => line.match(/create context/)).length).toBe(2); - expect(lines.filter(line => line.match(/close context/)).length).toBe(2); - - expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2); - expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2); - - expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2); - expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2); - }).toPass(); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create SSE session': 2, + 'delete SSE session': 2, + 'obtain browser (persistent)': 2, + 'create context': 2, + 'create browser context (persistent)': 2, + 'close browser': 2, + }); }); test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { @@ -266,29 +249,12 @@ test('sse transport shared context', async ({ serverEndpoint, server }) => { await client2.close(); - await expect(async () => { - const lines = stderr().split('\n'); - expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); - expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); - - // Should have only one context creation since it's shared - expect(lines.filter(line => line.match(/create shared browser context/)).length).toBe(1); - - // Should see client connect/disconnect messages - expect(lines.filter(line => line.match(/shared context client connected/)).length).toBe(2); - expect(lines.filter(line => line.match(/shared context client disconnected/)).length).toBe(2); - expect(lines.filter(line => line.match(/create context/)).length).toBe(2); - expect(lines.filter(line => line.match(/close context/)).length).toBe(2); - - // Context should only close when the server shuts down. - expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(0); - }).toPass(); - - await fetch(new URL('/killkillkill', url).href).catch(() => {}); - - await expect(async () => { - const lines = stderr().split('\n'); - // Context should only close when the server shuts down. - expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(1); - }).toPass(); + await expect.poll(() => formatLog(stderr())).toEqual({ + 'create SSE session': 2, + 'delete SSE session': 2, + 'obtain browser (persistent)': 1, + 'create browser context (persistent)': 1, + 'create context': 2, + 'close browser': 1, + }); });