fix: handle JavaScript dialogs opened during input tool actions (#2794)

Locator actions time out while the page is blocked by a JavaScript
dialog, even though the action was dispatched and opened that dialog.
That surfaced as an error contradicting the "Call handle_dialog to
handle it before continuing" hint in the same response. These are now
reported as an interruption instead of a failure, and `fill_form` stops
rather than trying to fill the remaining elements against a blocked
page.
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-09-22 14:55:23 +00:00
1 parent 3228f44af9
commit 266112b988
9 files changed
+525 -93

No files matched your search

+1 -1
View File
@@ -461,7 +461,7 @@ export class McpPage implements ContextPage {
}
waitForEventsAfterAction(
action: () => Promise<unknown>,
action: (signal: AbortSignal) => Promise<unknown>,
options?: {
timeout?: number;
waitForStableDom?: boolean;
+2
View File
@@ -46,6 +46,8 @@ export type ShapeOutput<T extends zod.ZodRawShape> = zod.output<
export {default as ajv} from 'ajv';
export {
Dialog,
ElementHandle,
Locator,
PredefinedNetworkConditions,
KnownDevices,
+1 -1
View File
@@ -379,7 +379,7 @@ export type ContextPage = Readonly<{
clearDialog(): void;
throwIfDialogOpen(): void;
waitForEventsAfterAction(
action: () => Promise<unknown>,
action: (signal: AbortSignal) => Promise<unknown>,
options?: {
timeout?: number;
waitForStableDom?: boolean;
+182 -80
View File
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {McpContext} from '../McpContext.js';
import {TimeoutError, zod} from '../third_party/index.js';
import type {ElementHandle, KeyInput} from '../third_party/index.js';
import type {TextSnapshotNode} from '../types.js';
@@ -33,7 +32,18 @@ const submitKeySchema = zod
'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"',
);
function handleActionError(error: unknown, uid: string) {
/**
* Locator actions abort or time out while the page is blocked by a JavaScript
* dialog, even though the action itself was dispatched and opened that dialog.
* The open dialog is already reported in the response, so treat this as an
* interruption instead of a failure.
*/
function handleActionError(error: unknown, uid: string, page: ContextPage) {
if (page.getDialog()) {
logger?.('action interrupted by a dialog', error);
return;
}
logger?.('failed to act using a locator', error);
const reason =
error instanceof TimeoutError
@@ -49,7 +59,10 @@ function handleActionError(error: unknown, uid: string) {
);
}
async function selectNativeSelectOption(handle: ElementHandle<Element>) {
async function selectNativeSelectOption(
handle: ElementHandle<Element>,
signal: AbortSignal,
) {
using selectHandle = await handle.evaluateHandle(node => {
if (!(node instanceof HTMLOptionElement)) {
return null;
@@ -82,7 +95,7 @@ async function selectNativeSelectOption(handle: ElementHandle<Element>) {
if (typeof value !== 'string') {
return false;
}
await select.asLocator().fill(value);
await select.asLocator().fill(value, {signal});
return true;
}
@@ -112,18 +125,21 @@ export const click = definePageTool(() => ({
const shouldSelectNativeOption =
!request.params.dblClick && aXNode?.role === 'option';
try {
const result = await request.page.waitForEventsAfterAction(async () => {
if (
shouldSelectNativeOption &&
(await selectNativeSelectOption(handle))
) {
return;
}
const result = await request.page.waitForEventsAfterAction(
async signal => {
if (
shouldSelectNativeOption &&
(await selectNativeSelectOption(handle, signal))
) {
return;
}
await handle.asLocator().click({
count: request.params.dblClick ? 2 : 1,
});
});
await handle.asLocator().click({
count: request.params.dblClick ? 2 : 1,
signal,
});
},
);
response.appendResponseLine(
request.params.dblClick
? `Successfully double clicked on the element`
@@ -134,7 +150,12 @@ export const click = definePageTool(() => ({
response.includeSnapshot();
}
} catch (error) {
handleActionError(error, uid);
handleActionError(error, uid, request.page);
response.appendResponseLine(
request.params.dblClick
? `The element was double clicked and it opened a dialog.`
: `The element was clicked and it opened a dialog.`,
);
}
},
}));
@@ -195,20 +216,50 @@ export const hover = definePageTool(() => ({
const uid = request.params.uid;
using handle = await request.page.getElementByUid(uid);
try {
const result = await request.page.waitForEventsAfterAction(async () => {
await handle.asLocator().hover();
});
const result = await request.page.waitForEventsAfterAction(
async signal => {
await handle.asLocator().hover({signal});
},
);
response.appendResponseLine(`Successfully hovered over the element`);
response.attachWaitForResult(result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
} catch (error) {
handleActionError(error, uid);
handleActionError(error, uid, request.page);
response.appendResponseLine(
`The element was hovered and it opened a dialog.`,
);
}
},
}));
function raceWithSignal<T>(
promise: Promise<T>,
signal: AbortSignal,
): Promise<T> {
if (signal.aborted) {
return Promise.reject(signal.reason);
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
reject(signal.reason);
};
signal.addEventListener('abort', onAbort, {once: true});
Promise.resolve(promise).then(
value => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
error => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
}
// The AXNode for an option doesn't contain its `value`. We set text content of the option as value.
// If the form is a combobox, we need to find the correct option by its text value.
// To do that, loop through the children while checking which child's text matches the requested value (requested value is actually the text content).
@@ -217,18 +268,25 @@ async function selectOption(
handle: ElementHandle,
aXNode: TextSnapshotNode,
value: string,
signal: AbortSignal,
) {
let optionFound = false;
for (const child of aXNode.children) {
if (child.role === 'option' && child.name === value && child.value) {
optionFound = true;
using childHandle = await child.elementHandle();
using childHandle = await raceWithSignal(child.elementHandle(), signal);
if (childHandle) {
using childValueHandle = await childHandle.getProperty('value');
using childValueHandle = await raceWithSignal(
childHandle.getProperty('value'),
signal,
);
const childValue = await childValueHandle.jsonValue();
const childValue = await raceWithSignal(
childValueHandle.jsonValue(),
signal,
);
if (typeof childValue === 'string') {
await handle.asLocator().fill(childValue);
await handle.asLocator().fill(childValue, {signal});
}
break;
@@ -244,46 +302,83 @@ function hasOptionChildren(aXNode: TextSnapshotNode) {
return aXNode.children.some(child => child.role === 'option');
}
async function fillFormElement(
uid: string,
value: string,
context: McpContext,
/**
* Fills one or more form elements and waits for resulting page events in a
* single `waitForEventsAfterAction` call.
* If an element opens a JavaScript dialog, returns the `interruptedUid` of that
* element so the caller can report it without filling remaining elements.
*/
async function fillFormElements(
elements: Array<{uid: string; value: string}>,
page: ContextPage,
) {
using handle = await page.getElementByUid(uid);
): Promise<{result?: WaitForEventsResult; interruptedUid?: string}> {
let currentUid: string | undefined;
try {
const aXNode = page.getAXNodeByUid(uid);
// We assume that combobox needs to be handled as select if it has
// role='combobox' and option children.
if (aXNode && aXNode.role === 'combobox' && hasOptionChildren(aXNode)) {
await selectOption(handle, aXNode, value);
} else {
const isToggle = await handle.evaluate(el => {
if (el instanceof HTMLInputElement) {
return el.type === 'checkbox' || el.type === 'radio';
}
const role = el.getAttribute('role');
return role === 'checkbox' || role === 'radio' || role === 'switch';
});
if (isToggle) {
if (['true', 'false'].includes(value)) {
await handle.asLocator().fill(value === 'true');
} else {
throw new Error(
`Checkboxes, radio boxes and toggles require "true" or "false" value, but ${value} was used`,
const result = await page.waitForEventsAfterAction(async signal => {
for (const {uid, value} of elements) {
const previousUid = currentUid;
currentUid = undefined;
using handle = await raceWithSignal(
page.getElementByUid(uid),
signal,
).catch(error => {
if (signal.aborted && previousUid) {
currentUid = previousUid;
}
throw error;
});
currentUid = uid;
const aXNode = page.getAXNodeByUid(uid);
// We assume that combobox needs to be handled as select if it has
// role='combobox' and option children.
if (aXNode && aXNode.role === 'combobox' && hasOptionChildren(aXNode)) {
await raceWithSignal(
selectOption(handle, aXNode, value, signal),
signal,
);
} else {
const isToggle = await raceWithSignal(
handle.evaluate(el => {
if (el instanceof HTMLInputElement) {
return el.type === 'checkbox' || el.type === 'radio';
}
const role = el.getAttribute('role');
return (
role === 'checkbox' || role === 'radio' || role === 'switch'
);
}),
signal,
);
if (isToggle) {
if (['true', 'false'].includes(value)) {
await handle.asLocator().fill(value === 'true', {signal});
} else {
throw new Error(
`Checkboxes, radio boxes and toggles require "true" or "false" value, but ${value} was used`,
);
}
} else {
// Increase timeout for longer input values.
const timeoutPerChar = 10; // ms
const fillTimeout =
page.pptrPage.getDefaultTimeout() + value.length * timeoutPerChar;
await handle
.asLocator()
.setTimeout(fillTimeout)
.fill(value, {signal});
}
}
} else {
// Increase timeout for longer input values.
const timeoutPerChar = 10; // ms
const fillTimeout =
page.pptrPage.getDefaultTimeout() + value.length * timeoutPerChar;
await handle.asLocator().setTimeout(fillTimeout).fill(value);
signal.throwIfAborted();
}
}
});
return {result};
} catch (error) {
handleActionError(error, uid);
if (!currentUid) {
throw error;
}
handleActionError(error, currentUid, page);
return {interruptedUid: currentUid};
}
}
@@ -309,16 +404,17 @@ export const fill = definePageTool(() => ({
},
blockedByDialog: true,
verifyFilesSchema: {},
handler: async (request, response, context) => {
const page = request.page;
const result = await page.waitForEventsAfterAction(async () => {
await fillFormElement(
request.params.uid,
request.params.value,
context as McpContext,
page,
handler: async (request, response) => {
const {result} = await fillFormElements(
[{uid: request.params.uid, value: request.params.value}],
request.page,
);
if (!result) {
response.appendResponseLine(
`The element was filled out and it opened a dialog.`,
);
});
return;
}
response.appendResponseLine(`Successfully filled out the element`);
response.attachWaitForResult(result);
if (request.params.includeSnapshot) {
@@ -418,21 +514,27 @@ export const fillForm = definePageTool(() => ({
},
blockedByDialog: true,
verifyFilesSchema: {},
handler: async (request, response, context) => {
const page = request.page;
let lastResult: WaitForEventsResult = {};
for (const element of request.params.elements) {
lastResult = await page.waitForEventsAfterAction(async () => {
await fillFormElement(
element.uid,
element.value,
context as McpContext,
page,
);
});
handler: async (request, response) => {
const {result, interruptedUid} = await fillFormElements(
request.params.elements,
request.page,
);
if (!result) {
// The page is blocked by a dialog, so the remaining elements cannot be
// filled out until it is handled.
const hasRemainingElements =
interruptedUid !== request.params.elements.at(-1)?.uid;
response.appendResponseLine(
`Filling out the element with uid ${interruptedUid} opened a dialog.${
hasRemainingElements
? ' The remaining elements were not filled out.'
: ''
}`,
);
return;
}
response.appendResponseLine(`Successfully filled out the form`);
response.attachWaitForResult(lastResult);
response.attachWaitForResult(result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
+11 -8
View File
@@ -100,6 +100,9 @@ export class WaitForHelper {
}
timeout(time: number): Promise<void> {
if (this.#abortController.signal.aborted) {
return Promise.resolve();
}
return new Promise<void>(res => {
const id = setTimeout(res, time);
this.#abortController.signal.addEventListener('abort', () => {
@@ -110,7 +113,7 @@ export class WaitForHelper {
}
async waitForEventsAfterAction(
action: () => Promise<unknown>,
action: (signal: AbortSignal) => Promise<unknown>,
options?: {
timeout?: number;
waitForStableDom?: boolean;
@@ -127,16 +130,12 @@ export class WaitForHelper {
) => {
this.#dialogDetected = true;
if (!options?.handleDialog) {
return;
}
let actionToTake: DialogAction | undefined;
if (typeof options.handleDialog === 'object') {
if (typeof options?.handleDialog === 'object') {
actionToTake = options.handleDialog[dialog.type()];
} else {
actionToTake = options.handleDialog;
actionToTake = options?.handleDialog;
}
if (actionToTake) {
@@ -148,6 +147,10 @@ export class WaitForHelper {
} else {
void dialog.accept(actionToTake);
}
} else {
this.#abortController.abort(
new Error('Action interrupted by a dialog'),
);
}
};
this.#page.on('dialog', dialogHandler);
@@ -231,7 +234,7 @@ export class WaitForHelper {
});
try {
await action();
await action(this.#abortController.signal);
} catch (error) {
// Clear up pending promises
this.#abortController.abort();
+1 -1
View File
@@ -1,5 +1,5 @@
exports[`e2e > Dialogs > returns blocked message when dialog is opened during tool execution 1`] = `
{"content":[{"type":"text","text":"# Open dialog\\nalert: test dialog.\\nCall handle_dialog to handle it before continuing.\\nError: Failed to interact with the element with uid 1_1. The element did not become interactive within the configured timeout."}],"isError":true}
{"content":[{"type":"text","text":"The element was clicked and it opened a dialog.\\n# Open dialog\\nalert: test dialog.\\nCall handle_dialog to handle it before continuing."}]}
`;
exports[`e2e > Dialogs > when dialog is open and tool is blocked, returns an error 1`] = `
+43
View File
@@ -43,11 +43,15 @@ import {
CdpFrame,
CdpPage,
DevTools,
Dialog,
ElementHandle,
Locator,
} from '../src/third_party/index.js';
import type {
Browser,
Extension,
Page,
Protocol,
Result,
RunnerResult,
} from '../src/third_party/index.js';
@@ -134,6 +138,19 @@ export function createMockPuppeteerPage(): sinon.SinonStubbedInstance<Page> {
const page = sinon.createStubInstance(
CdpPage,
) as unknown as sinon.SinonStubbedInstance<Page>;
const pageListener = mockListener();
page.on.callsFake((eventName, handler) => {
pageListener.on(eventName, handler);
return page;
});
page.off.callsFake((eventName, handler) => {
pageListener.off(eventName, handler);
return page;
});
page.emit.callsFake((eventName, data) => {
pageListener.emit(eventName, data);
return true;
});
// mainFrame() must return a stable object so tests can pass it back into
// page.emit('framenavigated', mainFrame) and have it recognized as the
@@ -180,9 +197,35 @@ export function createMockMcpPage(
): MockMcpPage {
const page = sinon.createStubInstance(McpPage);
const pptrPage = options.pptrPage ?? createMockPuppeteerPage();
page.waitForEventsAfterAction.callsFake(async action => {
await action(new AbortController().signal);
return {};
});
return Object.assign(page, {pptrPage});
}
export function createMockDialog(
options: {type?: Protocol.Page.DialogType; message?: string} = {},
): sinon.SinonStubbedInstance<Dialog> {
const dialog = sinon.createStubInstance(Dialog);
dialog.type.returns(options.type ?? 'alert');
dialog.message.returns(options.message ?? '');
return dialog;
}
export function createMockElementHandle(): {
handle: sinon.SinonStubbedInstance<ElementHandle<Element>>;
locator: sinon.SinonStubbedInstance<Locator<Element>>;
} {
const handle =
sinon.createStubInstance<ElementHandle<Element>>(ElementHandle);
handle.dispose.resolves();
const locator = sinon.createStubInstance<Locator<Element>>(Locator);
locator.setTimeout.returns(locator);
handle.asLocator.returns(locator);
return {handle, locator};
}
export function createMockMcpContext(
options: {selectedPage?: MockMcpPage} = {},
): MockMcpContext {
+238 -1
View File
@@ -7,7 +7,7 @@
import assert from 'node:assert';
import fs from 'node:fs/promises';
import path from 'node:path';
import {describe, it} from 'node:test';
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
@@ -24,12 +24,21 @@ import {
clickAt,
typeText,
} from '../../src/tools/input.js';
import {
createHandlerMocks,
createMockDialog,
createMockElementHandle,
} from '../mocks.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext, getTextContent} from '../utils.js';
describe('input', () => {
const server = serverHooks();
afterEach(() => {
sinon.restore();
});
describe('click', () => {
it('clicks', async () => {
await withMcpContext(async (response, context, args) => {
@@ -426,6 +435,59 @@ describe('input', () => {
);
});
});
it('does not fail when the click opens a dialog', async () => {
const {page, context, response, args} = createHandlerMocks();
const {handle, locator} = createMockElementHandle();
page.getElementByUid.resolves(handle);
locator.click.rejects(new Error('Timed out'));
page.getDialog.returns(createMockDialog({message: 'Are you sure?'}));
await click(args).handler(
{
params: {
uid: '1_1',
includeSnapshot: true,
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'The element was clicked and it opened a dialog.',
);
sinon.assert.notCalled(response.includeSnapshot);
});
it('does not fail when the double click opens a dialog', async () => {
const {page, context, response, args} = createHandlerMocks();
const {handle, locator} = createMockElementHandle();
page.getElementByUid.resolves(handle);
locator.click.rejects(new Error('Timed out'));
page.getDialog.returns(createMockDialog({message: 'Are you sure?'}));
await click(args).handler(
{
params: {
uid: '1_1',
dblClick: true,
includeSnapshot: true,
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'The element was double clicked and it opened a dialog.',
);
sinon.assert.notCalled(response.includeSnapshot);
});
});
describe('hover', () => {
@@ -456,6 +518,32 @@ describe('input', () => {
assert.ok(await page.$('text/hovered'));
});
});
it('does not fail when the hover opens a dialog', async () => {
const {page, context, response, args} = createHandlerMocks();
const {handle, locator} = createMockElementHandle();
page.getElementByUid.resolves(handle);
locator.hover.rejects(new Error('Timed out'));
page.getDialog.returns(createMockDialog({message: 'Hovered!'}));
await hover(args).handler(
{
params: {
uid: '1_1',
includeSnapshot: true,
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'The element was hovered and it opened a dialog.',
);
sinon.assert.notCalled(response.includeSnapshot);
});
});
describe('click_at', () => {
@@ -1068,6 +1156,34 @@ describe('input', () => {
assert.strictEqual(r2Checked, true);
});
});
it('does not fail when filling opens a dialog', async () => {
const {page, context, response, args} = createHandlerMocks();
const {handle, locator} = createMockElementHandle();
page.getElementByUid.resolves(handle);
page.pptrPage.getDefaultTimeout.returns(5000);
locator.fill.rejects(new Error('Timed out'));
page.getDialog.returns(createMockDialog({message: 'Invalid input'}));
await fill(args).handler(
{
params: {
uid: '1_1',
value: 'test',
includeSnapshot: true,
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'The element was filled out and it opened a dialog.',
);
sinon.assert.notCalled(response.includeSnapshot);
});
});
describe('drags', () => {
@@ -1240,6 +1356,127 @@ describe('input', () => {
);
});
});
it('stops filling remaining elements when an element opens a dialog', async () => {
const {page, context, response, args} = createHandlerMocks();
const {handle, locator} = createMockElementHandle();
page.getElementByUid.resolves(handle);
page.pptrPage.getDefaultTimeout.returns(5000);
locator.fill.rejects(new Error('Timed out'));
page.getDialog.returns(createMockDialog({message: 'Confirm input'}));
await fillForm(args).handler(
{
params: {
elements: [
{
uid: '1_1',
value: 'first',
},
{
uid: '1_2',
value: 'second',
},
],
includeSnapshot: true,
},
page,
},
response,
context,
);
sinon.assert.calledOnce(page.waitForEventsAfterAction);
sinon.assert.calledOnceWithExactly(page.getElementByUid, '1_1');
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'Filling out the element with uid 1_1 opened a dialog. The remaining elements were not filled out.',
);
sinon.assert.notCalled(response.attachWaitForResult);
sinon.assert.notCalled(response.includeSnapshot);
});
it('does not mention remaining elements when the last element opens a dialog', async () => {
const {page, context, response, args} = createHandlerMocks();
const {handle, locator} = createMockElementHandle();
page.getElementByUid.resolves(handle);
page.pptrPage.getDefaultTimeout.returns(5000);
locator.fill.onFirstCall().resolves();
locator.fill.onSecondCall().rejects(new Error('Timed out'));
page.getDialog.returns(createMockDialog({message: 'Confirm input'}));
await fillForm(args).handler(
{
params: {
elements: [
{
uid: '1_1',
value: 'first',
},
{
uid: '1_2',
value: 'second',
},
],
includeSnapshot: true,
},
page,
},
response,
context,
);
sinon.assert.calledOnce(page.waitForEventsAfterAction);
sinon.assert.calledTwice(page.getElementByUid);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'Filling out the element with uid 1_2 opened a dialog.',
);
sinon.assert.notCalled(response.attachWaitForResult);
sinon.assert.notCalled(response.includeSnapshot);
});
it('stops before advancing to the next element when signal aborts as locator.fill resolves', async () => {
const {page, context, response, args} = createHandlerMocks();
const {handle, locator} = createMockElementHandle();
page.getElementByUid.resolves(handle);
page.pptrPage.getDefaultTimeout.returns(5000);
page.getDialog.returns(createMockDialog({message: 'Confirm input'}));
page.waitForEventsAfterAction.callsFake(async action => {
const abortController = new AbortController();
locator.fill.callsFake(async () => {
abortController.abort(new Error('Action interrupted by a dialog'));
});
await action(abortController.signal);
return {};
});
await fillForm(args).handler(
{
params: {
elements: [
{
uid: '1_1',
value: 'first',
},
{
uid: '1_2',
value: 'second',
},
],
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(page.getElementByUid, '1_1');
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'Filling out the element with uid 1_1 opened a dialog. The remaining elements were not filled out.',
);
});
});
describe('uploadFile', () => {
+46 -1
View File
@@ -5,14 +5,59 @@
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
import {WaitForHelper} from '../../src/utils/WaitForHelper.js';
import {createMockDialog, createMockPuppeteerPage} from '../mocks.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext} from '../utils.js';
describe('WaitForHelper', () => {
const server = serverHooks();
afterEach(() => {
sinon.restore();
});
it('aborts the action signal when an unhandled dialog opens', async () => {
const pptrPage = createMockPuppeteerPage();
pptrPage.url.returns('https://example.com');
pptrPage.waitForNavigation.returns(Promise.withResolvers<null>().promise);
const helper = new WaitForHelper(pptrPage, 1, 1);
await assert.rejects(
helper.waitForEventsAfterAction(async signal => {
const aborted = new Promise<never>((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
});
pptrPage.emit('dialog', createMockDialog({message: 'Alert!'}));
await aborted;
}),
{
message: 'Action interrupted by a dialog',
},
);
});
it('resolves timeout immediately when dialog aborts during an action that does not throw', async () => {
const clock = sinon.useFakeTimers();
const pptrPage = createMockPuppeteerPage();
pptrPage.url.returns('https://example.com');
pptrPage.waitForNavigation.returns(Promise.withResolvers<null>().promise);
const helper = new WaitForHelper(pptrPage, 1, 1);
const result = await helper.waitForEventsAfterAction(async () => {
pptrPage.emit('dialog', createMockDialog({message: 'Leave site?'}));
});
assert.strictEqual(result.dialogHandled, false);
assert.strictEqual(clock.countTimers(), 0);
});
it('does not stall when an action opens a dialog without handleDialog', async () => {
await withMcpContext(async (response, context) => {
const mcpPage = context.getSelectedMcpPage();