From b85dc6954a00008ff4c16783894d10f050ece885 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 2 Sep 2025 08:59:36 +0200 Subject: [PATCH] chore(vscode): expose debug controller (#979) See https://github.com/microsoft/playwright-vscode/pull/684 for the other side. --- src/vscode/host.ts | 64 ++++++++++++++++++++++++++++++++++++++++--- tests/vscode.spec.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/vscode/host.ts b/src/vscode/host.ts index f871c1f..5795d7b 100644 --- a/src/vscode/host.ts +++ b/src/vscode/host.ts @@ -33,10 +33,12 @@ import { contextFactory } from '../browserContextFactory.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { ClientVersion, ServerBackend } from '../mcp/server.js'; import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { Browser, BrowserContext, BrowserServer } from 'playwright'; const contextSwitchOptions = z.object({ connectionString: z.string().optional().describe('The connection string to use to connect to the browser'), lib: z.string().optional().describe('The library to use for the connection'), + debugController: z.boolean().optional().describe('Enable the debug controller') }); class VSCodeProxyBackend implements ServerBackend { @@ -47,15 +49,18 @@ class VSCodeProxyBackend implements ServerBackend { private _contextSwitchTool: Tool; private _roots: Root[] = []; private _clientVersion?: ClientVersion; + private _context?: BrowserContext; + private _browser?: Browser; + private _browserServer?: BrowserServer; - constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise) { + constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: (delegate: VSCodeProxyBackend) => Promise) { this._contextSwitchTool = this._defineContextSwitchTool(); } async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise { this._clientVersion = clientVersion; this._roots = roots; - const transport = await this._defaultTransportFactory(); + const transport = await this._defaultTransportFactory(this); await this._setCurrentClient(transport); } @@ -80,9 +85,47 @@ class VSCodeProxyBackend implements ServerBackend { void this._currentClient?.close().catch(logUnhandledError); } + onContext(context: BrowserContext) { + this._context = context; + context.on('close', () => { + this._context = undefined; + }); + } + + private async _getDebugControllerURL() { + if (!this._context) + return; + + const browser = this._context.browser() as any; + if (!browser || !browser._launchServer) + return; + + if (this._browser !== browser) + this._browserServer = undefined; + + if (!this._browserServer) + this._browserServer = await browser._launchServer({ _debugController: true }) as BrowserServer; + + const url = new URL(this._browserServer.wsEndpoint()); + url.searchParams.set('debug-controller', '1'); + return url.toString(); + } + private async _callContextSwitchTool(params: z.infer): Promise { + if (params.debugController) { + const url = await this._getDebugControllerURL(); + const lines = [`### Result`]; + if (url) { + lines.push(`URL: ${url}`); + lines.push(`Version: ${packageJSON.dependencies.playwright}`); + } else { + lines.push(`No open browsers.`); + } + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } + if (!params.connectionString || !params.lib) { - const transport = await this._defaultTransportFactory(); + const transport = await this._defaultTransportFactory(this); await this._setCurrentClient(transport); return { content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }], @@ -142,7 +185,20 @@ export async function runVSCodeTools(config: FullConfig) { name: 'Playwright w/ vscode', nameInConfig: 'playwright-vscode', version: packageJSON.version, - create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config)))) + create: () => new VSCodeProxyBackend( + config, + delegate => mcpServer.wrapInProcess( + new BrowserServerBackend(config, + { + async createContext(clientInfo, abortSignal, toolName) { + const context = await contextFactory(config).createContext(clientInfo, abortSignal, toolName); + delegate.onContext(context.browserContext); + return context; + }, + } + ) + ) + ) }; await mcpServer.start(serverBackendFactory, config.server); return; diff --git a/tests/vscode.spec.ts b/tests/vscode.spec.ts index 6aef67e..3582ca3 100644 --- a/tests/vscode.spec.ts +++ b/tests/vscode.spec.ts @@ -52,3 +52,68 @@ test('browser_connect(vscode) works', async ({ startClient, playwright, browserN result: expect.stringContaining('ECONNREFUSED') }); }); + +test('browser_connect(debugController) works', async ({ startClient }) => { + test.skip(!globalThis.WebSocket, 'WebSocket is not supported in this environment'); + + const { client } = await startClient({ + args: ['--vscode'], + }); + + expect(await client.callTool({ + name: 'browser_connect', + arguments: { + debugController: true, + } + })).toHaveResponse({ + result: 'No open browsers.' + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,foo' + } + })).toHaveResponse({ + pageState: expect.stringContaining('foo'), + }); + + const response = await client.callTool({ + name: 'browser_connect', + arguments: { + debugController: true, + } + }); + expect(response.content?.[0].text).toMatch(/Version: \d+\.\d+\.\d+/); + const url = new URL(response.content?.[0].text.match(/URL: (.*)/)?.[1]); + const messages: unknown[] = []; + const socket = new WebSocket(url); + socket.onmessage = event => { + messages.push(JSON.parse(event.data)); + }; + await new Promise((resolve, reject) => { + socket.onopen = resolve; + socket.onerror = reject; + }); + + socket.send(JSON.stringify({ + id: '1', + guid: 'DebugController', + method: 'setReportStateChanged', + params: { + enabled: true, + }, + metadata: {}, + })); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,bar' + } + })).toHaveResponse({ + pageState: expect.stringContaining('bar'), + }); + + await expect.poll(() => messages).toContainEqual(expect.objectContaining({ method: 'stateChanged' })); +});