diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index 6e18d54d56b95..ba1b4a6cbec27 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -60,7 +60,7 @@ export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'one for (const key of keys) tokens.push(`${key}: ${formatObject(value[key])}`); if (mode === 'multiline') - return `{\n${tokens.join(`,\n${indent}`)}\n}`; + return `{\n${tokens.map(t => indent + t).join(`,\n`)}\n}`; return `{ ${tokens.join(', ')} }`; } return String(value); diff --git a/packages/playwright/src/mcp/browser/tools/mouse.ts b/packages/playwright/src/mcp/browser/tools/mouse.ts index 9ed4441c2092d..718c2cd136d6d 100644 --- a/packages/playwright/src/mcp/browser/tools/mouse.ts +++ b/packages/playwright/src/mcp/browser/tools/mouse.ts @@ -15,6 +15,7 @@ */ import { z } from 'playwright-core/lib/mcpBundle'; +import { formatObjectOrVoid } from 'playwright-core/lib/utils'; import { defineTabTool } from './tool'; const mouseMove = defineTabTool({ @@ -54,9 +55,12 @@ const mouseDown = defineTabTool({ }, handle: async (tab, params, response) => { + const options = { button: params.button }; + const optionsArg = formatObjectOrVoid(options); + response.addCode(`// Press mouse down`); - response.addCode(`await page.mouse.down({ button: '${params.button}' });`); - await tab.page.mouse.down({ button: params.button }); + response.addCode(`await page.mouse.down(${optionsArg});`); + await tab.page.mouse.down(options); }, }); @@ -74,9 +78,12 @@ const mouseUp = defineTabTool({ }, handle: async (tab, params, response) => { + const options = { button: params.button }; + const optionsArg = formatObjectOrVoid(options); + response.addCode(`// Press mouse up`); - response.addCode(`await page.mouse.up({ button: '${params.button}' });`); - await tab.page.mouse.up({ button: params.button }); + response.addCode(`await page.mouse.up(${optionsArg});`); + await tab.page.mouse.up(options); }, }); @@ -105,10 +112,12 @@ const mouseClick = defineTabTool({ schema: { name: 'browser_mouse_click_xy', title: 'Click', - description: 'Click left mouse button at a given position', + description: 'Click mouse button at a given position', inputSchema: z.object({ x: z.number().describe('X coordinate'), y: z.number().describe('Y coordinate'), + button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'), + clickCount: z.number().optional().describe('Number of clicks, defaults to 1'), }), type: 'input', }, @@ -116,15 +125,18 @@ const mouseClick = defineTabTool({ handle: async (tab, params, response) => { response.setIncludeSnapshot(); + const options = { + button: params.button, + clickCount: params.clickCount, + }; + const formatted = formatObjectOrVoid(options); + const optionsArg = formatted ? `, ${formatted}` : ''; + response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`); - response.addCode(`await page.mouse.move(${params.x}, ${params.y});`); - response.addCode(`await page.mouse.down();`); - response.addCode(`await page.mouse.up();`); + response.addCode(`await page.mouse.click(${params.x}, ${params.y}${optionsArg});`); await tab.waitForCompletion(async () => { - await tab.page.mouse.move(params.x, params.y); - await tab.page.mouse.down(); - await tab.page.mouse.up(); + await tab.page.mouse.click(params.x, params.y, options); }); }, }); diff --git a/packages/playwright/src/mcp/browser/tools/snapshot.ts b/packages/playwright/src/mcp/browser/tools/snapshot.ts index ad64d995d93e2..aae8a21cf9cee 100644 --- a/packages/playwright/src/mcp/browser/tools/snapshot.ts +++ b/packages/playwright/src/mcp/browser/tools/snapshot.ts @@ -15,7 +15,7 @@ */ import { z } from 'playwright-core/lib/mcpBundle'; -import { formatObject } from 'playwright-core/lib/utils'; +import { formatObject, formatObjectOrVoid } from 'playwright-core/lib/utils'; import { defineTabTool, defineTool } from './tool'; @@ -66,13 +66,12 @@ const click = defineTabTool({ button: params.button, modifiers: params.modifiers, }; - const formatted = formatObject(options, ' ', 'oneline'); - const optionsAttr = formatted !== '{}' ? formatted : ''; + const optionsArg = formatObjectOrVoid(options); if (params.doubleClick) - response.addCode(`await page.${resolved}.dblclick(${optionsAttr});`); + response.addCode(`await page.${resolved}.dblclick(${optionsArg});`); else - response.addCode(`await page.${resolved}.click(${optionsAttr});`); + response.addCode(`await page.${resolved}.click(${optionsArg});`); await tab.waitForCompletion(async () => { if (params.doubleClick) diff --git a/tests/mcp/click.spec.ts b/tests/mcp/click.spec.ts index 7d3d6d548917f..e140669c12e0c 100644 --- a/tests/mcp/click.spec.ts +++ b/tests/mcp/click.spec.ts @@ -99,7 +99,11 @@ test('browser_click (right)', async ({ client, server }) => { }, }); expect(result).toHaveResponse({ - code: `await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`, + code: [ + `await page.getByRole('button', { name: 'Menu' }).click({`, + ` button: 'right'`, + `});` + ].join('\n'), snapshot: expect.stringContaining(`button "Right clicked"`), }); }); @@ -130,7 +134,11 @@ test('browser_click (modifiers)', async ({ client, server, mcpBrowser }) => { modifiers: ['Control'], }, })).toHaveResponse({ - code: `await page.getByRole('button', { name: 'Submit' }).click({ modifiers: ['Control'] });`, + code: [ + `await page.getByRole('button', { name: 'Submit' }).click({`, + ` modifiers: ['Control']`, + `});` + ].join('\n'), snapshot: expect.stringContaining(`generic [ref=e3]: ctrlKey:true metaKey:false shiftKey:false altKey:false`), }); } @@ -143,7 +151,11 @@ test('browser_click (modifiers)', async ({ client, server, mcpBrowser }) => { modifiers: ['Shift'], }, })).toHaveResponse({ - code: `await page.getByRole('button', { name: 'Submit' }).click({ modifiers: ['Shift'] });`, + code: [ + `await page.getByRole('button', { name: 'Submit' }).click({`, + ` modifiers: ['Shift']`, + `});` + ].join('\n'), snapshot: expect.stringContaining(`generic [ref=e3]: ctrlKey:false metaKey:false shiftKey:true altKey:false`), }); @@ -155,7 +167,11 @@ test('browser_click (modifiers)', async ({ client, server, mcpBrowser }) => { modifiers: ['Shift', 'Alt'], }, })).toHaveResponse({ - code: `await page.getByRole('button', { name: 'Submit' }).click({ modifiers: ['Shift', 'Alt'] });`, + code: [ + `await page.getByRole('button', { name: 'Submit' }).click({`, + ` modifiers: ['Shift', 'Alt']`, + `});` + ].join('\n'), snapshot: expect.stringContaining(`generic [ref=e3]: ctrlKey:false metaKey:false shiftKey:true altKey:true`), }); }); diff --git a/tests/mcp/mouse.spec.ts b/tests/mcp/mouse.spec.ts new file mode 100644 index 0000000000000..7a660bd2c6505 --- /dev/null +++ b/tests/mcp/mouse.spec.ts @@ -0,0 +1,103 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures'; + +test.use({ + mcpCaps: ['vision'], +}); + +const eventsPage = ` + +
+ + + +`; + +test.beforeEach(async ({ client, server }) => { + server.setContent('/', eventsPage, 'text/html'); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); +}); + +test('browser_mouse_click_xy (default)', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_mouse_click_xy', + arguments: { x: 100, y: 100 }, + })).toHaveResponse({ + code: expect.stringContaining('await page.mouse.click(100, 100);'), + snapshot: expect.stringMatching(/mousemove 100 100.*mousedown button:0.*mouseup button:0.*click button:0/s), + }); +}); + +test('browser_mouse_click_xy (right button)', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_mouse_click_xy', + arguments: { x: 100, y: 100, button: 'right' }, + })).toHaveResponse({ + code: expect.stringContaining(`await page.mouse.click(100, 100, {\n button: 'right'\n});`), + snapshot: expect.stringMatching(/mousemove 100 100.*mousedown button:2.*contextmenu button:2.*mouseup button:2/s), + }); +}); + +test('browser_mouse_click_xy (middle button)', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_mouse_click_xy', + arguments: { x: 100, y: 100, button: 'middle' }, + })).toHaveResponse({ + code: expect.stringContaining(`await page.mouse.click(100, 100, {\n button: 'middle'\n});`), + snapshot: expect.stringMatching(/mousemove 100 100.*mousedown button:1.*mouseup button:1/s), + }); +}); + +test('browser_mouse_click_xy (double click)', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_mouse_click_xy', + arguments: { x: 100, y: 100, clickCount: 2 }, + })).toHaveResponse({ + code: expect.stringContaining(`await page.mouse.click(100, 100, {\n clickCount: 2\n});`), + snapshot: expect.stringMatching(/mousemove 100 100.*mousedown button:0.*mouseup button:0.*dblclick button:0/s), + }); +});