Skip to content

Commit b85dc69

Browse files
authored
chore(vscode): expose debug controller (microsoft#979)
See microsoft/playwright-vscode#684 for the other side.
1 parent e8e2af4 commit b85dc69

File tree

2 files changed

+125
-4
lines changed

2 files changed

+125
-4
lines changed

src/vscode/host.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ import { contextFactory } from '../browserContextFactory.js';
3333
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
3434
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
3535
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
36+
import type { Browser, BrowserContext, BrowserServer } from 'playwright';
3637

3738
const contextSwitchOptions = z.object({
3839
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
3940
lib: z.string().optional().describe('The library to use for the connection'),
41+
debugController: z.boolean().optional().describe('Enable the debug controller')
4042
});
4143

4244
class VSCodeProxyBackend implements ServerBackend {
@@ -47,15 +49,18 @@ class VSCodeProxyBackend implements ServerBackend {
4749
private _contextSwitchTool: Tool;
4850
private _roots: Root[] = [];
4951
private _clientVersion?: ClientVersion;
52+
private _context?: BrowserContext;
53+
private _browser?: Browser;
54+
private _browserServer?: BrowserServer;
5055

51-
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise<Transport>) {
56+
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: (delegate: VSCodeProxyBackend) => Promise<Transport>) {
5257
this._contextSwitchTool = this._defineContextSwitchTool();
5358
}
5459

5560
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
5661
this._clientVersion = clientVersion;
5762
this._roots = roots;
58-
const transport = await this._defaultTransportFactory();
63+
const transport = await this._defaultTransportFactory(this);
5964
await this._setCurrentClient(transport);
6065
}
6166

@@ -80,9 +85,47 @@ class VSCodeProxyBackend implements ServerBackend {
8085
void this._currentClient?.close().catch(logUnhandledError);
8186
}
8287

88+
onContext(context: BrowserContext) {
89+
this._context = context;
90+
context.on('close', () => {
91+
this._context = undefined;
92+
});
93+
}
94+
95+
private async _getDebugControllerURL() {
96+
if (!this._context)
97+
return;
98+
99+
const browser = this._context.browser() as any;
100+
if (!browser || !browser._launchServer)
101+
return;
102+
103+
if (this._browser !== browser)
104+
this._browserServer = undefined;
105+
106+
if (!this._browserServer)
107+
this._browserServer = await browser._launchServer({ _debugController: true }) as BrowserServer;
108+
109+
const url = new URL(this._browserServer.wsEndpoint());
110+
url.searchParams.set('debug-controller', '1');
111+
return url.toString();
112+
}
113+
83114
private async _callContextSwitchTool(params: z.infer<typeof contextSwitchOptions>): Promise<CallToolResult> {
115+
if (params.debugController) {
116+
const url = await this._getDebugControllerURL();
117+
const lines = [`### Result`];
118+
if (url) {
119+
lines.push(`URL: ${url}`);
120+
lines.push(`Version: ${packageJSON.dependencies.playwright}`);
121+
} else {
122+
lines.push(`No open browsers.`);
123+
}
124+
return { content: [{ type: 'text', text: lines.join('\n') }] };
125+
}
126+
84127
if (!params.connectionString || !params.lib) {
85-
const transport = await this._defaultTransportFactory();
128+
const transport = await this._defaultTransportFactory(this);
86129
await this._setCurrentClient(transport);
87130
return {
88131
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
@@ -142,7 +185,20 @@ export async function runVSCodeTools(config: FullConfig) {
142185
name: 'Playwright w/ vscode',
143186
nameInConfig: 'playwright-vscode',
144187
version: packageJSON.version,
145-
create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config))))
188+
create: () => new VSCodeProxyBackend(
189+
config,
190+
delegate => mcpServer.wrapInProcess(
191+
new BrowserServerBackend(config,
192+
{
193+
async createContext(clientInfo, abortSignal, toolName) {
194+
const context = await contextFactory(config).createContext(clientInfo, abortSignal, toolName);
195+
delegate.onContext(context.browserContext);
196+
return context;
197+
},
198+
}
199+
)
200+
)
201+
)
146202
};
147203
await mcpServer.start(serverBackendFactory, config.server);
148204
return;

tests/vscode.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,68 @@ test('browser_connect(vscode) works', async ({ startClient, playwright, browserN
5252
result: expect.stringContaining('ECONNREFUSED')
5353
});
5454
});
55+
56+
test('browser_connect(debugController) works', async ({ startClient }) => {
57+
test.skip(!globalThis.WebSocket, 'WebSocket is not supported in this environment');
58+
59+
const { client } = await startClient({
60+
args: ['--vscode'],
61+
});
62+
63+
expect(await client.callTool({
64+
name: 'browser_connect',
65+
arguments: {
66+
debugController: true,
67+
}
68+
})).toHaveResponse({
69+
result: 'No open browsers.'
70+
});
71+
72+
expect(await client.callTool({
73+
name: 'browser_navigate',
74+
arguments: {
75+
url: 'data:text/html,foo'
76+
}
77+
})).toHaveResponse({
78+
pageState: expect.stringContaining('foo'),
79+
});
80+
81+
const response = await client.callTool({
82+
name: 'browser_connect',
83+
arguments: {
84+
debugController: true,
85+
}
86+
});
87+
expect(response.content?.[0].text).toMatch(/Version: \d+\.\d+\.\d+/);
88+
const url = new URL(response.content?.[0].text.match(/URL: (.*)/)?.[1]);
89+
const messages: unknown[] = [];
90+
const socket = new WebSocket(url);
91+
socket.onmessage = event => {
92+
messages.push(JSON.parse(event.data));
93+
};
94+
await new Promise((resolve, reject) => {
95+
socket.onopen = resolve;
96+
socket.onerror = reject;
97+
});
98+
99+
socket.send(JSON.stringify({
100+
id: '1',
101+
guid: 'DebugController',
102+
method: 'setReportStateChanged',
103+
params: {
104+
enabled: true,
105+
},
106+
metadata: {},
107+
}));
108+
109+
expect(await client.callTool({
110+
name: 'browser_navigate',
111+
arguments: {
112+
url: 'data:text/html,bar'
113+
}
114+
})).toHaveResponse({
115+
pageState: expect.stringContaining('bar'),
116+
});
117+
118+
await expect.poll(() => messages).toContainEqual(expect.objectContaining({ method: 'stateChanged' }));
119+
});

0 commit comments

Comments
 (0)