mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
3228f44af9
commit
266112b988
9 files changed
+525
-93
No files matched your search
+1
-1
@@ -461,7 +461,7 @@ export class McpPage implements ContextPage {
|
||||
}
|
||||
|
||||
waitForEventsAfterAction(
|
||||
action: () => Promise<unknown>,
|
||||
action: (signal: AbortSignal) => Promise<unknown>,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
waitForStableDom?: boolean;
|
||||
|
||||
Vendored
+2
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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,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
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user