From c9b4f71d6e67b45838f9b83344ed8dc2c7a7fa93 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Sat, 17 Jan 2026 08:37:42 +0100 Subject: [PATCH 1/2] fix: handle beforeunload dialogs in navigations --- docs/tool-reference.md | 1 + scripts/test.mjs | 1 + src/tools/pages.ts | 137 +++++++++++++++++++++++--------------- tests/tools/pages.test.ts | 63 +++++++++++++++++- 4 files changed, 146 insertions(+), 56 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index ac8ab1f53..c6720a602 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, decline or ignore potential before unload dialogs triggered by this navigation. - **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..b26072553 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, decline or ignore potential before unload dialogs triggered by this navigation.', + ), ...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( From e1fd1cccebd7d5545a5e5db4c8a9e163e7e27c80 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 19 Jan 2026 10:53:01 +0100 Subject: [PATCH 2/2] chore: improve doc --- docs/tool-reference.md | 2 +- src/tools/pages.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index c6720a602..d363c5ab8 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -148,7 +148,7 @@ **Parameters:** -- **handleBeforeUnload** (enum: "accept", "decline") _(optional)_: Whether to auto accept, decline or ignore potential before unload dialogs triggered by this navigation. +- **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/src/tools/pages.ts b/src/tools/pages.ts index b26072553..7ad9426ea 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -125,7 +125,7 @@ export const navigatePage = defineTool({ .enum(['accept', 'decline']) .optional() .describe( - 'Whether to auto accept, decline or ignore potential before unload dialogs triggered by this navigation.', + 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.', ), ...timeoutSchema, },