chore(vscode): expose debug controller (#979)
See https://github.com/microsoft/playwright-vscode/pull/684 for the other side.
This commit is contained in:
@@ -33,10 +33,12 @@ import { contextFactory } from '../browserContextFactory.js';
|
|||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
|
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
|
||||||
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Browser, BrowserContext, BrowserServer } from 'playwright';
|
||||||
|
|
||||||
const contextSwitchOptions = z.object({
|
const contextSwitchOptions = z.object({
|
||||||
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
|
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'),
|
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 {
|
class VSCodeProxyBackend implements ServerBackend {
|
||||||
@@ -47,15 +49,18 @@ class VSCodeProxyBackend implements ServerBackend {
|
|||||||
private _contextSwitchTool: Tool;
|
private _contextSwitchTool: Tool;
|
||||||
private _roots: Root[] = [];
|
private _roots: Root[] = [];
|
||||||
private _clientVersion?: ClientVersion;
|
private _clientVersion?: ClientVersion;
|
||||||
|
private _context?: BrowserContext;
|
||||||
|
private _browser?: Browser;
|
||||||
|
private _browserServer?: BrowserServer;
|
||||||
|
|
||||||
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise<Transport>) {
|
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: (delegate: VSCodeProxyBackend) => Promise<Transport>) {
|
||||||
this._contextSwitchTool = this._defineContextSwitchTool();
|
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||||
this._clientVersion = clientVersion;
|
this._clientVersion = clientVersion;
|
||||||
this._roots = roots;
|
this._roots = roots;
|
||||||
const transport = await this._defaultTransportFactory();
|
const transport = await this._defaultTransportFactory(this);
|
||||||
await this._setCurrentClient(transport);
|
await this._setCurrentClient(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +85,47 @@ class VSCodeProxyBackend implements ServerBackend {
|
|||||||
void this._currentClient?.close().catch(logUnhandledError);
|
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<typeof contextSwitchOptions>): Promise<CallToolResult> {
|
private async _callContextSwitchTool(params: z.infer<typeof contextSwitchOptions>): Promise<CallToolResult> {
|
||||||
|
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) {
|
if (!params.connectionString || !params.lib) {
|
||||||
const transport = await this._defaultTransportFactory();
|
const transport = await this._defaultTransportFactory(this);
|
||||||
await this._setCurrentClient(transport);
|
await this._setCurrentClient(transport);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
|
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
|
||||||
@@ -142,7 +185,20 @@ export async function runVSCodeTools(config: FullConfig) {
|
|||||||
name: 'Playwright w/ vscode',
|
name: 'Playwright w/ vscode',
|
||||||
nameInConfig: 'playwright-vscode',
|
nameInConfig: 'playwright-vscode',
|
||||||
version: packageJSON.version,
|
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);
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -52,3 +52,68 @@ test('browser_connect(vscode) works', async ({ startClient, playwright, browserN
|
|||||||
result: expect.stringContaining('ECONNREFUSED')
|
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' }));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user