mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
Stacked on #3299. Replaces `assert.ok(a === b)` and `assert.ok(a !== b)` with `assert.strictEqual(a, b)` and `assert.notStrictEqual(a, b)` in 5 test files, so that a failure shows both values. `WaitForHelper.test.ts` uses `assert.fail()` behind an `if` instead, because the code after the check relies on the narrowed type and `assert.notStrictEqual` does not narrow. This matches the DevTools [`prefer-assert-strict-equal`](https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/scripts/eslint_rules/lib/prefer-assert-strict-equal.ts) ESLint rule. ## User-visible changes None. Tests only. ## Testing - `npm run format` - `npm run test`
227 lines
8.3 KiB
TypeScript
227 lines
8.3 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import sinon from 'sinon';
|
|
|
|
import {WaitForHelper} from '../../src/utils/WaitForHelper.js';
|
|
import {
|
|
createMockDialog,
|
|
createMockPuppeteerPage,
|
|
createMockPuppeteerTarget,
|
|
} from '../mocks.js';
|
|
import {serverHooks} from '../server.js';
|
|
import {html, withMcpContext} from '../utils.js';
|
|
|
|
describe('WaitForHelper', () => {
|
|
const server = serverHooks();
|
|
|
|
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();
|
|
await mcpPage.pptrPage.setContent(html`<button id="b">go</button>`);
|
|
|
|
// The action opens a dialog asynchronously and passes no handleDialog.
|
|
// The dialog leaves the renderer paused; without the fix,
|
|
// waitForStableDom's setup evaluation would hang until protocolTimeout
|
|
// (~180s) while the context lock is held, freezing the session.
|
|
try {
|
|
const result = await Promise.race([
|
|
mcpPage.waitForEventsAfterAction(async () => {
|
|
await mcpPage.pptrPage.evaluate(() => {
|
|
setTimeout(() => confirm('blocked?'), 0);
|
|
});
|
|
}),
|
|
// Comfortably above WaitForHelper.#stableDomTimeout (3s): the call
|
|
// should return well within this once the dialog is detected.
|
|
new Promise<'stalled'>(resolve =>
|
|
setTimeout(() => resolve('stalled'), 5_000),
|
|
),
|
|
]);
|
|
|
|
if (result === 'stalled') {
|
|
assert.fail(
|
|
'stalled because a dialog was shown; would time out with ProtocolError',
|
|
);
|
|
}
|
|
// The dialog was detected but not handled (no handleDialog was passed).
|
|
assert.strictEqual(result.dialogHandled, false);
|
|
// The dialog is still open and recorded, so the next blockedByDialog tool
|
|
// correctly refuses to run.
|
|
assert.throws(() => mcpPage.throwIfDialogOpen());
|
|
} finally {
|
|
await mcpPage.getDialog()?.dismiss();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('awaits navigation when action takes longer than expectNavigationIn', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
server.addHtmlRoute('/nav-initial', html`<main>initial</main>`);
|
|
server.addHtmlRoute('/nav-target', html`<main>navigated</main>`);
|
|
const startUrl = server.getRoute('/nav-initial');
|
|
const url = server.getRoute('/nav-target');
|
|
const mcpPage = context.getSelectedMcpPage();
|
|
await mcpPage.pptrPage.goto(startUrl);
|
|
|
|
const result = await mcpPage.waitForEventsAfterAction(
|
|
async () => {
|
|
// Simulate an action that takes longer than expectNavigationIn (300ms)
|
|
// before triggering the navigation.
|
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
await mcpPage.pptrPage.evaluate(targetUrl => {
|
|
location.href = targetUrl;
|
|
}, url);
|
|
},
|
|
{waitForStableDom: false, expectNavigationIn: 300},
|
|
);
|
|
|
|
assert.strictEqual(result.navigatedToUrl, url);
|
|
});
|
|
});
|
|
|
|
it('does not hang when an iframe navigates', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
server.addHtmlRoute('/iframe-src', html`<p>iframe</p>`);
|
|
server.addHtmlRoute('/iframe-target', html`<p>iframe navigated</p>`);
|
|
const iframeSrc = server.getRoute('/iframe-src');
|
|
const iframeTarget = server.getRoute('/iframe-target');
|
|
const mcpPage = context.getSelectedMcpPage();
|
|
await mcpPage.pptrPage.setContent(
|
|
html`<iframe
|
|
id="subframe"
|
|
src="${iframeSrc}"
|
|
></iframe>`,
|
|
);
|
|
|
|
const startTime = Date.now();
|
|
const result = await mcpPage.waitForEventsAfterAction(
|
|
async () => {
|
|
await mcpPage.pptrPage.evaluate(targetUrl => {
|
|
const frame = document.querySelector('iframe');
|
|
if (!frame) {
|
|
throw new Error('iframe not found');
|
|
}
|
|
frame.src = targetUrl;
|
|
}, iframeTarget);
|
|
},
|
|
{waitForStableDom: false, expectNavigationIn: 50, timeout: 2000},
|
|
);
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
assert(
|
|
elapsed < 1500,
|
|
`Took ${elapsed}ms; should not hang waiting for iframe`,
|
|
);
|
|
assert.strictEqual(result.navigatedToUrl, undefined);
|
|
});
|
|
});
|
|
|
|
it('awaits navigation when preceded by same-document navigation', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
server.addHtmlRoute('/nav-start', html`<main>start</main>`);
|
|
server.addHtmlRoute('/nav-target-2', html`<main>navigated 2</main>`);
|
|
const startUrl = server.getRoute('/nav-start');
|
|
const targetUrl = server.getRoute('/nav-target-2');
|
|
const mcpPage = context.getSelectedMcpPage();
|
|
await mcpPage.pptrPage.goto(startUrl);
|
|
|
|
const result = await mcpPage.waitForEventsAfterAction(
|
|
async () => {
|
|
await mcpPage.pptrPage.evaluate(url => {
|
|
history.pushState({}, '', '/intermediate-state');
|
|
location.href = url;
|
|
}, targetUrl);
|
|
},
|
|
{waitForStableDom: false, expectNavigationIn: 1000},
|
|
);
|
|
|
|
assert.strictEqual(result.navigatedToUrl, targetUrl);
|
|
});
|
|
});
|
|
|
|
it('captures navigatedToUrl for same-document navigation alone', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
server.addHtmlRoute('/nav-start-push', html`<main>start push</main>`);
|
|
server.addHtmlRoute('/same-doc-target', html`<main>target</main>`);
|
|
const startUrl = server.getRoute('/nav-start-push');
|
|
const targetUrl = server.getRoute('/same-doc-target');
|
|
const mcpPage = context.getSelectedMcpPage();
|
|
await mcpPage.pptrPage.goto(startUrl);
|
|
|
|
const result = await mcpPage.waitForEventsAfterAction(
|
|
async () => {
|
|
await mcpPage.pptrPage.evaluate(url => {
|
|
history.pushState({}, '', url);
|
|
}, targetUrl);
|
|
},
|
|
{waitForStableDom: false},
|
|
);
|
|
|
|
assert.strictEqual(result.navigatedToUrl, targetUrl);
|
|
});
|
|
});
|
|
|
|
it('captures popup targets opened by the action', async () => {
|
|
const pptrPage = createMockPuppeteerPage();
|
|
pptrPage.url.returns('https://example.com');
|
|
pptrPage.waitForNavigation.returns(Promise.withResolvers<null>().promise);
|
|
const popupPage = createMockPuppeteerPage();
|
|
const popupTarget = createMockPuppeteerTarget({page: popupPage});
|
|
const helper = new WaitForHelper(pptrPage, 1, 1);
|
|
|
|
const result = await helper.waitForEventsAfterAction(
|
|
async () => {
|
|
assert.strictEqual(pptrPage.listenerCount('popup'), 1);
|
|
pptrPage.emit('popup', popupPage);
|
|
},
|
|
{waitForStableDom: false, expectNavigationIn: 0},
|
|
);
|
|
|
|
assert.strictEqual(result.openedPages?.has(popupTarget), true);
|
|
assert.strictEqual(pptrPage.listenerCount('popup'), 0);
|
|
});
|
|
});
|