chore: allow reusing tab over cdp (#170)

Fixes https://github.com/microsoft/playwright-mcp/issues/164
This commit is contained in:
Pavel Feldman 2025-04-14 16:39:58 -07:00 committed by GitHub
parent e729494bd9
commit 606b898a71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 63 additions and 21 deletions

View File

@ -22,6 +22,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Run linting
run: npm run lint

View File

@ -54,7 +54,7 @@ export class Context {
currentTab(): Tab {
if (!this._currentTab)
throw new Error('Navigate to a location to create a tab');
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
return this._currentTab;
}
@ -236,8 +236,8 @@ class Tab {
});
}
async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
return await this.run(callback, {
async runAndWaitWithSnapshot(callback: (snapshot: PageSnapshot) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
return await this.run(tab => callback(tab.lastSnapshot()), {
captureSnapshot: true,
waitForCompletion: true,
...options,

View File

@ -28,7 +28,7 @@ const screenshot: Tool = {
},
handle: async context => {
const tab = context.currentTab();
const tab = await context.ensureTab();
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
return {
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],

View File

@ -29,7 +29,8 @@ const snapshot: Tool = {
},
handle: async context => {
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
const tab = await context.ensureTab();
return await tab.run(async () => {}, { captureSnapshot: true });
},
};
@ -48,8 +49,8 @@ const click: Tool = {
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
await locator.click();
}, {
status: `Clicked "${validatedParams.element}"`,
@ -74,9 +75,9 @@ const drag: Tool = {
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const startLocator = tab.lastSnapshot().refLocator(validatedParams.startRef);
const endLocator = tab.lastSnapshot().refLocator(validatedParams.endRef);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const startLocator = snapshot.refLocator(validatedParams.startRef);
const endLocator = snapshot.refLocator(validatedParams.endRef);
await startLocator.dragTo(endLocator);
}, {
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
@ -94,8 +95,8 @@ const hover: Tool = {
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
await locator.hover();
}, {
status: `Hovered over "${validatedParams.element}"`,
@ -119,8 +120,8 @@ const type: Tool = {
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
if (validatedParams.slowly)
await locator.pressSequentially(validatedParams.text);
else
@ -147,8 +148,8 @@ const selectOption: Tool = {
handle: async (context, params) => {
const validatedParams = selectOptionSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
await locator.selectOption(validatedParams.values);
}, {
status: `Selected option in "${validatedParams.element}"`,

View File

@ -89,7 +89,7 @@ const closeTab: ToolFactory = captureSnapshot => ({
handle: async (context, params) => {
const validatedParams = closeTabSchema.parse(params);
await context.closeTab(validatedParams.index);
const currentTab = await context.currentTab();
const currentTab = context.currentTab();
if (currentTab)
return await currentTab.run(async () => {}, { captureSnapshot });
return {

View File

@ -35,3 +35,27 @@ Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body>
`
);
});
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Hello, world!',
ref: 'f0',
},
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toHaveTextContent(`
- Page URL: data:text/html,hello world
- Page Title:
- Page Snapshot
\`\`\`yaml
- text: hello world
\`\`\`
`);
});

View File

@ -20,6 +20,7 @@ import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { spawn } from 'child_process';
type Fixtures = {
client: Client;
@ -68,12 +69,25 @@ export const test = baseTest.extend<Fixtures>({
cdpEndpoint: async ({ }, use, testInfo) => {
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
channel: 'chrome',
args: [`--remote-debugging-port=${port}`],
const executablePath = chromium.executablePath();
const browserProcess = spawn(executablePath, [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--headless`,
`data:text/html,hello world`,
], {
stdio: 'pipe',
});
await new Promise<void>(resolve => {
browserProcess.stderr.on('data', data => {
if (data.toString().includes('DevTools listening on '))
resolve();
});
});
await use(`http://localhost:${port}`);
await browser.close();
browserProcess.kill();
},
});