fix: handle beforeunload dialogs in navigations (#788)

Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/713
This commit is contained in:
Alex Rudenko authored and GitHub committed 2026-01-19 10:04:45 +00:00
1 parent 7ba63a8777
commit 9b21f8b2e9
4 files changed
+146 -56

No files matched your search

+1
View File
@@ -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.
+1
View File
@@ -53,6 +53,7 @@ const nodeArgs = [
'spec',
'--test-force-exit',
'--test',
'--test-timeout=30000',
...flags,
...files,
];
+82 -55
View File
@@ -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);
},
+62 -1
View File
@@ -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` <script>
window.addEventListener('beforeunload', e => {
e.preventDefault();
e.returnValue = '';
});
</script>`,
);
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` <script>
window.addEventListener('beforeunload', e => {
e.preventDefault();
e.returnValue = '';
});
</script>`,
);
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(