From 90e06f0bca0142031dc32011d4f7d8d454975d30 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Feb 2026 12:30:06 -0800 Subject: [PATCH 1/4] feat(mcp): add button and clickCount options to browser_mouse_click_xy Fixes #39269 --- .../playwright/src/mcp/browser/tools/mouse.ts | 36 +++++-- tests/mcp/mouse.spec.ts | 101 ++++++++++++++++++ 2 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 tests/mcp/mouse.spec.ts diff --git a/packages/playwright/src/mcp/browser/tools/mouse.ts b/packages/playwright/src/mcp/browser/tools/mouse.ts index 9ed4441c2092d..ef97ec8c1b23b 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 { formatObject } from 'playwright-core/lib/utils'; import { defineTabTool } from './tool'; const mouseMove = defineTabTool({ @@ -54,9 +55,13 @@ const mouseDown = defineTabTool({ }, handle: async (tab, params, response) => { + const options = { button: params.button }; + const formatted = formatObject(options, ' ', 'oneline'); + const optionsAttr = formatted !== '{}' ? formatted : ''; + 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(${optionsAttr});`); + await tab.page.mouse.down(options); }, }); @@ -74,9 +79,13 @@ const mouseUp = defineTabTool({ }, handle: async (tab, params, response) => { + const options = { button: params.button }; + const formatted = formatObject(options, ' ', 'oneline'); + const optionsAttr = formatted !== '{}' ? formatted : ''; + 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(${optionsAttr});`); + await tab.page.mouse.up(options); }, }); @@ -105,10 +114,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 +127,18 @@ const mouseClick = defineTabTool({ handle: async (tab, params, response) => { response.setIncludeSnapshot(); + const options = { + button: params.button, + clickCount: params.clickCount, + }; + const formatted = formatObject(options, ' ', 'oneline'); + const optionsAttr = 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}${optionsAttr});`); 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/tests/mcp/mouse.spec.ts b/tests/mcp/mouse.spec.ts new file mode 100644 index 0000000000000..d7482f973b252 --- /dev/null +++ b/tests/mcp/mouse.spec.ts @@ -0,0 +1,101 @@ +/** + * 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 as baseTest, expect } from './fixtures'; + +const test = baseTest.extend({ mcpCaps: [['vision'], { option: true }] }); + +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, { button: 'right' });`), + 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, { button: 'middle' });`), + 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, { clickCount: 2 });`), + snapshot: expect.stringMatching(/mousemove 100 100.*mousedown button:0.*mouseup button:0.*dblclick button:0/s), + }); +}); From 250458d1bc1379e2528d2badc0147aaf28a982f7 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Feb 2026 12:51:35 -0800 Subject: [PATCH 2/4] simplify formatting --- .../src/utils/isomorphic/stringUtils.ts | 2 +- .../playwright/src/mcp/browser/tools/mouse.ts | 18 ++++++++---------- .../src/mcp/browser/tools/snapshot.ts | 9 ++++----- tests/mcp/mouse.spec.ts | 6 +++--- 4 files changed, 16 insertions(+), 19 deletions(-) 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 ef97ec8c1b23b..718c2cd136d6d 100644 --- a/packages/playwright/src/mcp/browser/tools/mouse.ts +++ b/packages/playwright/src/mcp/browser/tools/mouse.ts @@ -15,7 +15,7 @@ */ import { z } from 'playwright-core/lib/mcpBundle'; -import { formatObject } from 'playwright-core/lib/utils'; +import { formatObjectOrVoid } from 'playwright-core/lib/utils'; import { defineTabTool } from './tool'; const mouseMove = defineTabTool({ @@ -56,11 +56,10 @@ const mouseDown = defineTabTool({ handle: async (tab, params, response) => { const options = { button: params.button }; - const formatted = formatObject(options, ' ', 'oneline'); - const optionsAttr = formatted !== '{}' ? formatted : ''; + const optionsArg = formatObjectOrVoid(options); response.addCode(`// Press mouse down`); - response.addCode(`await page.mouse.down(${optionsAttr});`); + response.addCode(`await page.mouse.down(${optionsArg});`); await tab.page.mouse.down(options); }, }); @@ -80,11 +79,10 @@ const mouseUp = defineTabTool({ handle: async (tab, params, response) => { const options = { button: params.button }; - const formatted = formatObject(options, ' ', 'oneline'); - const optionsAttr = formatted !== '{}' ? formatted : ''; + const optionsArg = formatObjectOrVoid(options); response.addCode(`// Press mouse up`); - response.addCode(`await page.mouse.up(${optionsAttr});`); + response.addCode(`await page.mouse.up(${optionsArg});`); await tab.page.mouse.up(options); }, }); @@ -131,11 +129,11 @@ const mouseClick = defineTabTool({ button: params.button, clickCount: params.clickCount, }; - const formatted = formatObject(options, ' ', 'oneline'); - const optionsAttr = formatted !== '{}' ? `, ${formatted}` : ''; + const formatted = formatObjectOrVoid(options); + const optionsArg = formatted ? `, ${formatted}` : ''; response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`); - response.addCode(`await page.mouse.click(${params.x}, ${params.y}${optionsAttr});`); + response.addCode(`await page.mouse.click(${params.x}, ${params.y}${optionsArg});`); await tab.waitForCompletion(async () => { 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/mouse.spec.ts b/tests/mcp/mouse.spec.ts index d7482f973b252..b5df57820e46b 100644 --- a/tests/mcp/mouse.spec.ts +++ b/tests/mcp/mouse.spec.ts @@ -75,7 +75,7 @@ test('browser_mouse_click_xy (right button)', async ({ client }) => { name: 'browser_mouse_click_xy', arguments: { x: 100, y: 100, button: 'right' }, })).toHaveResponse({ - code: expect.stringContaining(`await page.mouse.click(100, 100, { button: 'right' });`), + 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), }); }); @@ -85,7 +85,7 @@ test('browser_mouse_click_xy (middle button)', async ({ client }) => { name: 'browser_mouse_click_xy', arguments: { x: 100, y: 100, button: 'middle' }, })).toHaveResponse({ - code: expect.stringContaining(`await page.mouse.click(100, 100, { button: 'middle' });`), + 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), }); }); @@ -95,7 +95,7 @@ test('browser_mouse_click_xy (double click)', async ({ client }) => { name: 'browser_mouse_click_xy', arguments: { x: 100, y: 100, clickCount: 2 }, })).toHaveResponse({ - code: expect.stringContaining(`await page.mouse.click(100, 100, { clickCount: 2 });`), + 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), }); }); From 1d7cf8a3d3d010c5a2fded2e56f2b15d18d9f200 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Feb 2026 14:04:29 -0800 Subject: [PATCH 3/4] fix lint --- tests/mcp/mouse.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/mcp/mouse.spec.ts b/tests/mcp/mouse.spec.ts index b5df57820e46b..7a660bd2c6505 100644 --- a/tests/mcp/mouse.spec.ts +++ b/tests/mcp/mouse.spec.ts @@ -14,9 +14,11 @@ * limitations under the License. */ -import { test as baseTest, expect } from './fixtures'; +import { test, expect } from './fixtures'; -const test = baseTest.extend({ mcpCaps: [['vision'], { option: true }] }); +test.use({ + mcpCaps: ['vision'], +}); const eventsPage = ` From be4a7b643ef12a19e6223102656cab4f11401d9e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Feb 2026 15:06:58 -0800 Subject: [PATCH 4/4] update expectations --- tests/mcp/click.spec.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) 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`), }); });