Skip to content

Commit 9b21f8b

Browse files
authored
fix: handle beforeunload dialogs in navigations (#788)
Closes #713
1 parent 7ba63a8 commit 9b21f8b

File tree

4 files changed

+146
-56
lines changed

4 files changed

+146
-56
lines changed

docs/tool-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148

149149
**Parameters:**
150150

151+
- **handleBeforeUnload** (enum: "accept", "decline") _(optional)_: Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.
151152
- **ignoreCache** (boolean) _(optional)_: Whether to ignore cache on reload.
152153
- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used.
153154
- **type** (enum: "url", "back", "forward", "reload") _(optional)_: Navigate the page by URL, back or forward in history, or reload.

scripts/test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const nodeArgs = [
5353
'spec',
5454
'--test-force-exit',
5555
'--test',
56+
'--test-timeout=30000',
5657
...flags,
5758
...files,
5859
];

src/tools/pages.ts

Lines changed: 82 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import {logger} from '../logger.js';
8+
import type {Dialog} from '../third_party/index.js';
89
import {zod} from '../third_party/index.js';
910

1011
import {ToolCategory} from './categories.js';
@@ -120,6 +121,12 @@ export const navigatePage = defineTool({
120121
.boolean()
121122
.optional()
122123
.describe('Whether to ignore cache on reload.'),
124+
handleBeforeUnload: zod
125+
.enum(['accept', 'decline'])
126+
.optional()
127+
.describe(
128+
'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.',
129+
),
123130
...timeoutSchema,
124131
},
125132
handler: async (request, response, context) => {
@@ -136,62 +143,82 @@ export const navigatePage = defineTool({
136143
request.params.type = 'url';
137144
}
138145

139-
await context.waitForEventsAfterAction(async () => {
140-
switch (request.params.type) {
141-
case 'url':
142-
if (!request.params.url) {
143-
throw new Error('A URL is required for navigation of type=url.');
144-
}
145-
try {
146-
await page.goto(request.params.url, options);
147-
response.appendResponseLine(
148-
`Successfully navigated to ${request.params.url}.`,
149-
);
150-
} catch (error) {
151-
response.appendResponseLine(
152-
`Unable to navigate in the selected page: ${error.message}.`,
153-
);
154-
}
155-
break;
156-
case 'back':
157-
try {
158-
await page.goBack(options);
159-
response.appendResponseLine(
160-
`Successfully navigated back to ${page.url()}.`,
161-
);
162-
} catch (error) {
163-
response.appendResponseLine(
164-
`Unable to navigate back in the selected page: ${error.message}.`,
165-
);
166-
}
167-
break;
168-
case 'forward':
169-
try {
170-
await page.goForward(options);
171-
response.appendResponseLine(
172-
`Successfully navigated forward to ${page.url()}.`,
173-
);
174-
} catch (error) {
175-
response.appendResponseLine(
176-
`Unable to navigate forward in the selected page: ${error.message}.`,
177-
);
178-
}
179-
break;
180-
case 'reload':
181-
try {
182-
await page.reload({
183-
...options,
184-
ignoreCache: request.params.ignoreCache,
185-
});
186-
response.appendResponseLine(`Successfully reloaded the page.`);
187-
} catch (error) {
188-
response.appendResponseLine(
189-
`Unable to reload the selected page: ${error.message}.`,
190-
);
191-
}
192-
break;
146+
const handleBeforeUnload = request.params.handleBeforeUnload ?? 'accept';
147+
const dialogHandler = (dialog: Dialog) => {
148+
if (dialog.type() === 'beforeunload') {
149+
if (handleBeforeUnload === 'accept') {
150+
response.appendResponseLine(`Accepted a beforeunload dialog.`);
151+
void dialog.accept();
152+
} else {
153+
response.appendResponseLine(`Declined a beforeunload dialog.`);
154+
void dialog.dismiss();
155+
}
156+
// We are not going to report the dialog like regular dialogs.
157+
context.clearDialog();
193158
}
194-
});
159+
};
160+
page.on('dialog', dialogHandler);
161+
162+
try {
163+
await context.waitForEventsAfterAction(async () => {
164+
switch (request.params.type) {
165+
case 'url':
166+
if (!request.params.url) {
167+
throw new Error('A URL is required for navigation of type=url.');
168+
}
169+
try {
170+
await page.goto(request.params.url, options);
171+
response.appendResponseLine(
172+
`Successfully navigated to ${request.params.url}.`,
173+
);
174+
} catch (error) {
175+
response.appendResponseLine(
176+
`Unable to navigate in the selected page: ${error.message}.`,
177+
);
178+
}
179+
break;
180+
case 'back':
181+
try {
182+
await page.goBack(options);
183+
response.appendResponseLine(
184+
`Successfully navigated back to ${page.url()}.`,
185+
);
186+
} catch (error) {
187+
response.appendResponseLine(
188+
`Unable to navigate back in the selected page: ${error.message}.`,
189+
);
190+
}
191+
break;
192+
case 'forward':
193+
try {
194+
await page.goForward(options);
195+
response.appendResponseLine(
196+
`Successfully navigated forward to ${page.url()}.`,
197+
);
198+
} catch (error) {
199+
response.appendResponseLine(
200+
`Unable to navigate forward in the selected page: ${error.message}.`,
201+
);
202+
}
203+
break;
204+
case 'reload':
205+
try {
206+
await page.reload({
207+
...options,
208+
ignoreCache: request.params.ignoreCache,
209+
});
210+
response.appendResponseLine(`Successfully reloaded the page.`);
211+
} catch (error) {
212+
response.appendResponseLine(
213+
`Unable to reload the selected page: ${error.message}.`,
214+
);
215+
}
216+
break;
217+
}
218+
});
219+
} finally {
220+
page.off('dialog', dialogHandler);
221+
}
195222

196223
response.setIncludePages(true);
197224
},

tests/tools/pages.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
handleDialog,
2020
getTabId,
2121
} from '../../src/tools/pages.js';
22-
import {withMcpContext} from '../utils.js';
22+
import {html, withMcpContext} from '../utils.js';
2323

2424
describe('pages', () => {
2525
describe('list_pages', () => {
@@ -184,6 +184,67 @@ describe('pages', () => {
184184
assert.ok(response.includePages);
185185
});
186186
});
187+
188+
it('reload with accpeting the beforeunload dialog', async () => {
189+
await withMcpContext(async (response, context) => {
190+
const page = context.getSelectedPage();
191+
await page.setContent(
192+
html` <script>
193+
window.addEventListener('beforeunload', e => {
194+
e.preventDefault();
195+
e.returnValue = '';
196+
});
197+
</script>`,
198+
);
199+
200+
await navigatePage.handler(
201+
{params: {type: 'reload'}},
202+
response,
203+
context,
204+
);
205+
206+
assert.strictEqual(context.getDialog(), undefined);
207+
assert.ok(response.includePages);
208+
assert.strictEqual(
209+
response.responseLines.join('\n'),
210+
'Accepted a beforeunload dialog.\nSuccessfully reloaded the page.',
211+
);
212+
});
213+
});
214+
215+
it('reload with declining the beforeunload dialog', async () => {
216+
await withMcpContext(async (response, context) => {
217+
const page = context.getSelectedPage();
218+
await page.setContent(
219+
html` <script>
220+
window.addEventListener('beforeunload', e => {
221+
e.preventDefault();
222+
e.returnValue = '';
223+
});
224+
</script>`,
225+
);
226+
227+
await navigatePage.handler(
228+
{
229+
params: {
230+
type: 'reload',
231+
handleBeforeUnload: 'decline',
232+
timeout: 500,
233+
},
234+
},
235+
response,
236+
context,
237+
);
238+
239+
assert.strictEqual(context.getDialog(), undefined);
240+
assert.ok(response.includePages);
241+
assert.strictEqual(
242+
response.responseLines.join('\n'),
243+
'Declined a beforeunload dialog.\nUnable to reload the selected page: Navigation timeout of 500 ms exceeded.',
244+
);
245+
});
246+
});
247+
187248
it('go forward with error', async () => {
188249
await withMcpContext(async (response, context) => {
189250
await navigatePage.handler(

0 commit comments

Comments
 (0)