From a392ba2f41e8bfa02c42e006128c982510527ee9 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 25 Mar 2025 13:05:28 -0700 Subject: [PATCH] chore: restart browser if page closed manually (#19) Fixes https://github.com/microsoft/playwright-mcp/issues/18 --- src/context.ts | 30 ++++++++++----- src/server.ts | 4 +- tests/basic.spec.ts | 66 +++++++++++++++++++++++++++++++++ tests/fixtures.ts | 90 ++++++++++++++++++++++++++++----------------- 4 files changed, 143 insertions(+), 47 deletions(-) diff --git a/src/context.ts b/src/context.ts index 9afea59..abce420 100644 --- a/src/context.ts +++ b/src/context.ts @@ -18,6 +18,7 @@ import * as playwright from 'playwright'; export class Context { private _launchOptions: playwright.LaunchOptions; + private _browser: playwright.Browser | undefined; private _page: playwright.Page | undefined; private _console: playwright.ConsoleMessage[] = []; private _initializePromise: Promise | undefined; @@ -39,30 +40,39 @@ export class Context { async close() { const page = await this.ensurePage(); await page.close(); - this._initializePromise = undefined; } private async _initialize() { if (this._initializePromise) return this._initializePromise; this._initializePromise = (async () => { - const browser = await this._createBrowser(); - this._page = await browser.newPage(); + this._browser = await createBrowser(this._launchOptions); + this._page = await this._browser.newPage(); this._page.on('console', event => this._console.push(event)); this._page.on('framenavigated', frame => { if (!frame.parentFrame()) this._console.length = 0; }); + this._page.on('close', () => this._reset()); })(); return this._initializePromise; } - private async _createBrowser(): Promise { - if (process.env.PLAYWRIGHT_WS_ENDPOINT) { - const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT); - url.searchParams.set('launch-options', JSON.stringify(this._launchOptions)); - return await playwright.chromium.connect(String(url)); - } - return await playwright.chromium.launch({ channel: 'chrome', ...this._launchOptions }); + private _reset() { + const browser = this._browser; + this._initializePromise = undefined; + this._browser = undefined; + this._page = undefined; + this._console.length = 0; + void browser?.close(); } } + +async function createBrowser(launchOptions: playwright.LaunchOptions): Promise { + if (process.env.PLAYWRIGHT_WS_ENDPOINT) { + const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT); + url.searchParams.set('launch-options', JSON.stringify(launchOptions)); + return await playwright.chromium.connect(String(url)); + } + return await playwright.chromium.launch({ channel: 'chrome', ...launchOptions }); +} diff --git a/src/server.ts b/src/server.ts index 3e79212..ae05d2e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,6 @@ import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import * as playwright from 'playwright'; import { Context } from './context'; @@ -31,7 +30,6 @@ export type LaunchOptions = { export class Server { private _server: MCPServer; private _tools: Tool[]; - private _page: playwright.Page | undefined; private _context: Context; constructor(options: { name: string, version: string, tools: Tool[], resources: Resource[] }, launchOptions: LaunchOptions) { @@ -90,6 +88,6 @@ export class Server { async stop() { await this._server.close(); - await this._page?.context()?.browser()?.close(); + await this._context.close(); } } diff --git a/tests/basic.spec.ts b/tests/basic.spec.ts index 4878a0e..e4cc310 100644 --- a/tests/basic.spec.ts +++ b/tests/basic.spec.ts @@ -161,3 +161,69 @@ test('test browser_click', async ({ server }) => { }, })); }); + +test('test reopen browser', async ({ server }) => { + const response2 = await server.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { + url: 'data:text/html,TitleHello, world!', + }, + }, + }); + + expect(response2).toEqual(expect.objectContaining({ + id: 2, + })); + + const response3 = await server.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_close', + }, + }); + + expect(response3).toEqual(expect.objectContaining({ + id: 3, + result: { + content: [{ + text: 'Page closed', + type: 'text', + }], + }, + })); + + const response4 = await server.send({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { + url: 'data:text/html,TitleHello, world!', + }, + }, + }); + + expect(response4).toEqual(expect.objectContaining({ + id: 4, + result: { + content: [{ + type: 'text', + text: ` +- Page URL: data:text/html,TitleHello, world! +- Page Title: Title +- Page Snapshot +\`\`\`yaml +- document [ref=s1e2]: Hello, world! +\`\`\` +`, + }], + }, + })); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index b597322..7248123 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -17,6 +17,7 @@ import path from 'path'; import { spawn } from 'child_process'; import EventEmitter from 'events'; +import { chromium } from 'playwright'; import { test as baseTest, expect } from '@playwright/test'; @@ -30,10 +31,11 @@ class MCPServer extends EventEmitter { private _messageResolvers: ((value: any) => void)[] = []; private _buffer: string = ''; - constructor(command: string, args: string[]) { + constructor(command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) { super(); this._child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...options?.env }, }); this._child.stdout?.on('data', data => { @@ -108,44 +110,64 @@ class MCPServer extends EventEmitter { } } -export const test = baseTest.extend<{ server: MCPServer }>({ - server: async ({}, use) => { - const server = new MCPServer('node', [path.join(__dirname, '../cli.js'), '--headless']); - const initialize = await server.send({ - jsonrpc: '2.0', - id: 0, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { - name: 'Playwright Test', - version: '0.0.0', - }, - }, - }); +type Fixtures = { + server: MCPServer; + startServer: (options?: { env?: NodeJS.ProcessEnv }) => Promise; + wsEndpoint: string; +}; - expect(initialize).toEqual(expect.objectContaining({ - id: 0, - result: expect.objectContaining({ - protocolVersion: '2024-11-05', - capabilities: { - tools: {}, - resources: {}, +export const test = baseTest.extend({ + server: async ({ startServer }, use) => { + await use(await startServer()); + }, + + startServer: async ({ }, use) => { + let server: MCPServer | undefined; + + use(async options => { + server = new MCPServer('node', [path.join(__dirname, '../cli.js'), '--headless'], options); + const initialize = await server.send({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'Playwright Test', + version: '0.0.0', + }, }, - serverInfo: expect.objectContaining({ - name: 'Playwright', - version: expect.any(String), + }); + + expect(initialize).toEqual(expect.objectContaining({ + id: 0, + result: expect.objectContaining({ + protocolVersion: '2024-11-05', + capabilities: { + tools: {}, + resources: {}, + }, + serverInfo: expect.objectContaining({ + name: 'Playwright', + version: expect.any(String), + }), }), - }), - })); + })); - await server.sendNoReply({ - jsonrpc: '2.0', - method: 'notifications/initialized', + await server.sendNoReply({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }); + return server; }); - await use(server); - await server.close(); + await server?.close(); + }, + + wsEndpoint: async ({ }, use) => { + const browserServer = await chromium.launchServer(); + await use(browserServer.wsEndpoint()); + await browserServer.close(); }, });