diff --git a/README.md b/README.md index 5e42796..3f83e76 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,7 @@ http.createServer(async (req, res) => { - `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 - `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** diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index d8ee2a7..e91f2be 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -19,12 +19,11 @@ import net from 'node:net'; import path from 'node:path'; import os from 'node:os'; -import debug from 'debug'; 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 { if (browserConfig.remoteEndpoint) @@ -84,10 +83,10 @@ class BaseContextFactory implements BrowserContextFactory { testDebug(`close browser context (${this.name})`); if (browser.contexts().length === 1) this._browserPromise = undefined; - await browserContext.close().catch(() => {}); + await browserContext.close().catch(logUnhandledError); if (browser.contexts().length === 0) { testDebug(`close browser (${this.name})`); - await browser.close().catch(() => {}); + await browser.close().catch(logUnhandledError); } } } diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..15d09dd --- /dev/null +++ b/src/log.ts @@ -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'); diff --git a/src/tab.ts b/src/tab.ts index f91a969..d16f3ca 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -18,6 +18,7 @@ import * as playwright from 'playwright'; import { PageSnapshot } from './pageSnapshot.js'; import { callOnPageNoTrace } from './tools/utils.js'; +import { logUnhandledError } from './log.js'; import type { Context } from './context.js'; @@ -68,13 +69,13 @@ export class Tab { } async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise { - 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) { this._clearCollectedArtifacts(); - const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {})); + const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError)); try { await this.page.goto(url, { waitUntil: 'domcontentloaded' }); } catch (_e: unknown) { diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 8e43c68..1cca749 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -48,6 +48,7 @@ export const elementSchema = z.object({ const clickSchema = elementSchema.extend({ 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({ @@ -63,19 +64,21 @@ const click = defineTool({ handle: async (context, params) => { const tab = context.currentTabOrDie(); const locator = tab.snapshotOrDie().refLocator(params); + const button = params.button; + const buttonAttr = button ? `{ button: '${button}' }` : ''; const code: string[] = []; if (params.doubleClick) { code.push(`// Double click ${params.element}`); - code.push(`await page.${await generateLocator(locator)}.dblclick();`); + code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`); } else { code.push(`// Click ${params.element}`); - code.push(`await page.${await generateLocator(locator)}.click();`); + code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`); } return { code, - action: () => params.doubleClick ? locator.dblclick() : locator.click(), + action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }), captureSnapshot: true, waitForNetwork: true, }; diff --git a/tests/click.spec.ts b/tests/click.spec.ts new file mode 100644 index 0000000..b0132e3 --- /dev/null +++ b/tests/click.spec.ts @@ -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 + + `, '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 + +

Click me

+ `, '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('/', ` + + + `, '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"`); +}); diff --git a/tests/core.spec.ts b/tests/core.spec.ts index a3463fa..091c5da 100644 --- a/tests/core.spec.ts +++ b/tests/core.spec.ts @@ -37,78 +37,6 @@ await page.goto('${server.HELLO_WORLD}'); ); }); -test('browser_click', async ({ client, server, mcpBrowser }) => { - server.setContent('/', ` - Title - - `, '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 - -

Click me

- `, '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 }) => { server.setContent('/', ` Title