diff --git a/docs/tool-reference.md b/docs/tool-reference.md index ac8ab1f53..d363c5ab8 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -148,6 +148,7 @@ **Parameters:** +- **handleBeforeUnload** (enum: "accept", "decline") _(optional)_: Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept. - **ignoreCache** (boolean) _(optional)_: Whether to ignore cache on reload. - **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. - **type** (enum: "url", "back", "forward", "reload") _(optional)_: Navigate the page by URL, back or forward in history, or reload. diff --git a/scripts/test.mjs b/scripts/test.mjs index 796c13ad9..403bcd5dc 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -53,6 +53,7 @@ const nodeArgs = [ 'spec', '--test-force-exit', '--test', + '--test-timeout=30000', ...flags, ...files, ]; diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 6b8d4b739..7ad9426ea 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -5,6 +5,7 @@ */ import {logger} from '../logger.js'; +import type {Dialog} from '../third_party/index.js'; import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; @@ -120,6 +121,12 @@ export const navigatePage = defineTool({ .boolean() .optional() .describe('Whether to ignore cache on reload.'), + handleBeforeUnload: zod + .enum(['accept', 'decline']) + .optional() + .describe( + 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.', + ), ...timeoutSchema, }, handler: async (request, response, context) => { @@ -136,62 +143,82 @@ export const navigatePage = defineTool({ request.params.type = 'url'; } - await context.waitForEventsAfterAction(async () => { - switch (request.params.type) { - case 'url': - if (!request.params.url) { - throw new Error('A URL is required for navigation of type=url.'); - } - try { - await page.goto(request.params.url, options); - response.appendResponseLine( - `Successfully navigated to ${request.params.url}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate in the selected page: ${error.message}.`, - ); - } - break; - case 'back': - try { - await page.goBack(options); - response.appendResponseLine( - `Successfully navigated back to ${page.url()}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate back in the selected page: ${error.message}.`, - ); - } - break; - case 'forward': - try { - await page.goForward(options); - response.appendResponseLine( - `Successfully navigated forward to ${page.url()}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate forward in the selected page: ${error.message}.`, - ); - } - break; - case 'reload': - try { - await page.reload({ - ...options, - ignoreCache: request.params.ignoreCache, - }); - response.appendResponseLine(`Successfully reloaded the page.`); - } catch (error) { - response.appendResponseLine( - `Unable to reload the selected page: ${error.message}.`, - ); - } - break; + const handleBeforeUnload = request.params.handleBeforeUnload ?? 'accept'; + const dialogHandler = (dialog: Dialog) => { + if (dialog.type() === 'beforeunload') { + if (handleBeforeUnload === 'accept') { + response.appendResponseLine(`Accepted a beforeunload dialog.`); + void dialog.accept(); + } else { + response.appendResponseLine(`Declined a beforeunload dialog.`); + void dialog.dismiss(); + } + // We are not going to report the dialog like regular dialogs. + context.clearDialog(); } - }); + }; + page.on('dialog', dialogHandler); + + try { + await context.waitForEventsAfterAction(async () => { + switch (request.params.type) { + case 'url': + if (!request.params.url) { + throw new Error('A URL is required for navigation of type=url.'); + } + try { + await page.goto(request.params.url, options); + response.appendResponseLine( + `Successfully navigated to ${request.params.url}.`, + ); + } catch (error) { + response.appendResponseLine( + `Unable to navigate in the selected page: ${error.message}.`, + ); + } + break; + case 'back': + try { + await page.goBack(options); + response.appendResponseLine( + `Successfully navigated back to ${page.url()}.`, + ); + } catch (error) { + response.appendResponseLine( + `Unable to navigate back in the selected page: ${error.message}.`, + ); + } + break; + case 'forward': + try { + await page.goForward(options); + response.appendResponseLine( + `Successfully navigated forward to ${page.url()}.`, + ); + } catch (error) { + response.appendResponseLine( + `Unable to navigate forward in the selected page: ${error.message}.`, + ); + } + break; + case 'reload': + try { + await page.reload({ + ...options, + ignoreCache: request.params.ignoreCache, + }); + response.appendResponseLine(`Successfully reloaded the page.`); + } catch (error) { + response.appendResponseLine( + `Unable to reload the selected page: ${error.message}.`, + ); + } + break; + } + }); + } finally { + page.off('dialog', dialogHandler); + } response.setIncludePages(true); }, diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 7bf7e747d..d20a7a6cf 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -19,7 +19,7 @@ import { handleDialog, getTabId, } from '../../src/tools/pages.js'; -import {withMcpContext} from '../utils.js'; +import {html, withMcpContext} from '../utils.js'; describe('pages', () => { describe('list_pages', () => { @@ -184,6 +184,67 @@ describe('pages', () => { assert.ok(response.includePages); }); }); + + it('reload with accpeting the beforeunload dialog', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html` `, + ); + + await navigatePage.handler( + {params: {type: 'reload'}}, + response, + context, + ); + + assert.strictEqual(context.getDialog(), undefined); + assert.ok(response.includePages); + assert.strictEqual( + response.responseLines.join('\n'), + 'Accepted a beforeunload dialog.\nSuccessfully reloaded the page.', + ); + }); + }); + + it('reload with declining the beforeunload dialog', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html` `, + ); + + await navigatePage.handler( + { + params: { + type: 'reload', + handleBeforeUnload: 'decline', + timeout: 500, + }, + }, + response, + context, + ); + + assert.strictEqual(context.getDialog(), undefined); + assert.ok(response.includePages); + assert.strictEqual( + response.responseLines.join('\n'), + 'Declined a beforeunload dialog.\nUnable to reload the selected page: Navigation timeout of 500 ms exceeded.', + ); + }); + }); + it('go forward with error', async () => { await withMcpContext(async (response, context) => { await navigatePage.handler(