mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-26 08:32:26 +08:00
chore: allow right click (#687)
Fixes https://github.com/microsoft/playwright-mcp/issues/467
This commit is contained in:
parent
fe0c0ffffe
commit
c97bc6e2ae
@ -376,6 +376,7 @@ http.createServer(async (req, res) => {
|
|||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||||
|
- `button` (string, optional): Button to click, defaults to left
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
@ -19,12 +19,11 @@ import net from 'node:net';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
||||||
import debug from 'debug';
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
import { logUnhandledError, testDebug } from './log.js';
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||||
if (browserConfig.remoteEndpoint)
|
if (browserConfig.remoteEndpoint)
|
||||||
@ -84,10 +83,10 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
testDebug(`close browser context (${this.name})`);
|
testDebug(`close browser context (${this.name})`);
|
||||||
if (browser.contexts().length === 1)
|
if (browser.contexts().length === 1)
|
||||||
this._browserPromise = undefined;
|
this._browserPromise = undefined;
|
||||||
await browserContext.close().catch(() => {});
|
await browserContext.close().catch(logUnhandledError);
|
||||||
if (browser.contexts().length === 0) {
|
if (browser.contexts().length === 0) {
|
||||||
testDebug(`close browser (${this.name})`);
|
testDebug(`close browser (${this.name})`);
|
||||||
await browser.close().catch(() => {});
|
await browser.close().catch(logUnhandledError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
25
src/log.ts
Normal file
25
src/log.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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 debug from 'debug';
|
||||||
|
|
||||||
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
|
|
||||||
|
export function logUnhandledError(error: unknown) {
|
||||||
|
errorsDebug(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const testDebug = debug('pw:mcp:test');
|
@ -18,6 +18,7 @@ import * as playwright from 'playwright';
|
|||||||
|
|
||||||
import { PageSnapshot } from './pageSnapshot.js';
|
import { PageSnapshot } from './pageSnapshot.js';
|
||||||
import { callOnPageNoTrace } from './tools/utils.js';
|
import { callOnPageNoTrace } from './tools/utils.js';
|
||||||
|
import { logUnhandledError } from './log.js';
|
||||||
|
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
@ -68,13 +69,13 @@ export class Tab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||||
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
|
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigate(url: string) {
|
async navigate(url: string) {
|
||||||
this._clearCollectedArtifacts();
|
this._clearCollectedArtifacts();
|
||||||
|
|
||||||
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
|
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
|
||||||
try {
|
try {
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
} catch (_e: unknown) {
|
} catch (_e: unknown) {
|
||||||
|
@ -48,6 +48,7 @@ export const elementSchema = z.object({
|
|||||||
|
|
||||||
const clickSchema = elementSchema.extend({
|
const clickSchema = elementSchema.extend({
|
||||||
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
||||||
|
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const click = defineTool({
|
const click = defineTool({
|
||||||
@ -63,19 +64,21 @@ const click = defineTool({
|
|||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const locator = tab.snapshotOrDie().refLocator(params);
|
const locator = tab.snapshotOrDie().refLocator(params);
|
||||||
|
const button = params.button;
|
||||||
|
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||||
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
if (params.doubleClick) {
|
if (params.doubleClick) {
|
||||||
code.push(`// Double click ${params.element}`);
|
code.push(`// Double click ${params.element}`);
|
||||||
code.push(`await page.${await generateLocator(locator)}.dblclick();`);
|
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||||
} else {
|
} else {
|
||||||
code.push(`// Click ${params.element}`);
|
code.push(`// Click ${params.element}`);
|
||||||
code.push(`await page.${await generateLocator(locator)}.click();`);
|
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
action: () => params.doubleClick ? locator.dblclick() : locator.click(),
|
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
|
||||||
captureSnapshot: true,
|
captureSnapshot: true,
|
||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
|
117
tests/click.spec.ts
Normal file
117
tests/click.spec.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* 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.js';
|
||||||
|
|
||||||
|
test('browser_click', async ({ client, server, mcpBrowser }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<button>Submit</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Submit button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Click Submit button
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_click (double)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<script>
|
||||||
|
function handle() {
|
||||||
|
document.querySelector('h1').textContent = 'Double clicked';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<h1 ondblclick="handle()">Click me</h1>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
doubleClick: true,
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Double click Click me
|
||||||
|
await page.getByRole('heading', { name: 'Click me' }).dblclick();
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- heading "Double clicked" [level=1] [ref=e3]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_click (right)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<button oncontextmenu="handle">Menu</button>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('contextmenu', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
document.querySelector('button').textContent = 'Right clicked';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Menu',
|
||||||
|
ref: 'e2',
|
||||||
|
button: 'right',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toContainTextContent(`await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`);
|
||||||
|
expect(result).toContainTextContent(`- button "Right clicked"`);
|
||||||
|
});
|
@ -37,78 +37,6 @@ await page.goto('${server.HELLO_WORLD}');
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_click', async ({ client, server, mcpBrowser }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<button>Submit</button>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Submit button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Click Submit button
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_click (double)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<script>
|
|
||||||
function handle() {
|
|
||||||
document.querySelector('h1').textContent = 'Double clicked';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<h1 ondblclick="handle()">Click me</h1>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Click me',
|
|
||||||
ref: 'e2',
|
|
||||||
doubleClick: true,
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Double click Click me
|
|
||||||
await page.getByRole('heading', { name: 'Click me' }).dblclick();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- heading "Double clicked" [level=1] [ref=e3]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_select_option', async ({ client, server }) => {
|
test('browser_select_option', async ({ client, server }) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user