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(