mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
feat: report new URL after actions that trigger navigation (#1853)
## Summary
- Input tools (`click`, `fill`, `press_key`, `hover`, `drag`,
`type_text`, `fill_form`, `click_at`) and `evaluate_script` now append a
`Page navigated to <url>.` line to the response when the action triggers
a cross-document navigation.
- `WaitForHelper.waitForEventsAfterAction` returns `{navigated:
boolean}` instead of `void`, surfacing the navigation signal that was
already being detected internally.
- No change to `navigate_page` or `new_page` since they already report
the URL explicitly.
Fixes #243
## Why
Today, if a `click` causes a page navigation, the response says
*"Successfully clicked on the element"* with no indication that the page
URL changed. The agent has to make an extra `list_pages` call to
discover where it landed. This saves that round-trip for every
navigation-triggering action.
## Design
The existing `waitForNavigationStarted` in `WaitForHelper` already knows
whether a cross-document navigation started. We propagate that signal as
`{navigated: boolean}` through the return value of
`waitForEventsAfterAction` → `McpPage` → `ContextPage` interface, and
let each handler append the URL line when `navigated` is true.
Same-document (history API) navigations remain filtered out by the
existing `waitForNavigationStarted` logic, matching current behavior.
Click-opens-new-tab is a separate concern (#367).
## Test plan
- [x] New test: click on a link that causes navigation → response
includes `Page navigated to <url>.`
- [x] New test: click on a button that doesn't navigate → no navigation
line in response
- [x] Full test suite (563 tests) passes
- [x] TypeScript typecheck clean
- [x] ESLint + Prettier clean
This commit is contained in:
+2
-1
@@ -28,6 +28,7 @@ import type {
|
||||
import {
|
||||
getNetworkMultiplierFromString,
|
||||
WaitForHelper,
|
||||
type WaitForEventsResult,
|
||||
} from './WaitForHelper.js';
|
||||
|
||||
/**
|
||||
@@ -132,7 +133,7 @@ export class McpPage implements ContextPage {
|
||||
waitForEventsAfterAction(
|
||||
action: () => Promise<unknown>,
|
||||
options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string},
|
||||
): Promise<void> {
|
||||
): Promise<WaitForEventsResult> {
|
||||
const helper = this.createWaitForHelper(
|
||||
this.cpuThrottlingRate,
|
||||
getNetworkMultiplierFromString(this.networkConditions),
|
||||
|
||||
+27
-2
@@ -127,7 +127,7 @@ export class WaitForHelper {
|
||||
async waitForEventsAfterAction(
|
||||
action: () => Promise<unknown>,
|
||||
options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string},
|
||||
): Promise<void> {
|
||||
): Promise<WaitForEventsResult> {
|
||||
let dialogOpened = false;
|
||||
if (options?.handleDialog) {
|
||||
const dialogHandler = (dialog: Pick<Dialog, 'accept' | 'dismiss'>) => {
|
||||
@@ -146,6 +146,7 @@ export class WaitForHelper {
|
||||
});
|
||||
}
|
||||
|
||||
const urlBeforeAction = this.#page.url();
|
||||
const navigationFinished = this.waitForNavigationStarted()
|
||||
.then(navigationStated => {
|
||||
if (navigationStated) {
|
||||
@@ -170,7 +171,7 @@ export class WaitForHelper {
|
||||
await navigationFinished;
|
||||
|
||||
if (dialogOpened) {
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Wait for stable dom after navigation so we execute in
|
||||
@@ -181,6 +182,30 @@ export class WaitForHelper {
|
||||
} finally {
|
||||
this.#abortController.abort();
|
||||
}
|
||||
|
||||
const urlAfterAction = this.#page.url();
|
||||
return {
|
||||
...(urlAfterAction !== urlBeforeAction
|
||||
? {navigatedToUrl: urlAfterAction}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface WaitForEventsResult {
|
||||
/**
|
||||
* The URL the page navigated to during the action, if a navigation
|
||||
* occurred.
|
||||
*/
|
||||
navigatedToUrl?: string;
|
||||
}
|
||||
|
||||
export function appendWaitForResult(
|
||||
response: {appendResponseLine(value: string): void},
|
||||
result: WaitForEventsResult,
|
||||
): void {
|
||||
if (result.navigatedToUrl) {
|
||||
response.appendResponseLine(`Page navigated to ${result.navigatedToUrl}.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ExtensionServiceWorker,
|
||||
} from '../types.js';
|
||||
import type {PaginationOptions} from '../utils/types.js';
|
||||
import type {WaitForEventsResult} from '../WaitForHelper.js';
|
||||
|
||||
import type {ToolCategory} from './categories.js';
|
||||
import type {
|
||||
@@ -260,7 +261,7 @@ export type ContextPage = Readonly<{
|
||||
waitForEventsAfterAction(
|
||||
action: () => Promise<unknown>,
|
||||
options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string},
|
||||
): Promise<void>;
|
||||
): Promise<WaitForEventsResult>;
|
||||
getThirdPartyDeveloperTools():
|
||||
| ToolGroup<ThirdPartyDeveloperToolDefinition>
|
||||
| undefined;
|
||||
|
||||
+21
-8
@@ -10,6 +10,10 @@ import {zod} from '../third_party/index.js';
|
||||
import type {ElementHandle, KeyInput} from '../third_party/index.js';
|
||||
import type {TextSnapshotNode} from '../types.js';
|
||||
import {parseKey} from '../utils/keyboard.js';
|
||||
import {
|
||||
appendWaitForResult,
|
||||
type WaitForEventsResult,
|
||||
} from '../WaitForHelper.js';
|
||||
|
||||
import {ToolCategory} from './categories.js';
|
||||
import type {ContextPage} from './ToolDefinition.js';
|
||||
@@ -109,7 +113,7 @@ export const click = definePageTool({
|
||||
const shouldSelectNativeOption =
|
||||
!request.params.dblClick && aXNode?.role === 'option';
|
||||
try {
|
||||
await request.page.waitForEventsAfterAction(async () => {
|
||||
const result = await request.page.waitForEventsAfterAction(async () => {
|
||||
if (
|
||||
shouldSelectNativeOption &&
|
||||
(await selectNativeSelectOption(handle))
|
||||
@@ -126,6 +130,7 @@ export const click = definePageTool({
|
||||
? `Successfully double clicked on the element`
|
||||
: `Successfully clicked on the element`,
|
||||
);
|
||||
appendWaitForResult(response, result);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
@@ -154,7 +159,7 @@ export const clickAt = definePageTool({
|
||||
blockedByDialog: true,
|
||||
handler: async (request, response) => {
|
||||
const page = request.page;
|
||||
await page.waitForEventsAfterAction(async () => {
|
||||
const result = await page.waitForEventsAfterAction(async () => {
|
||||
await page.pptrPage.mouse.click(request.params.x, request.params.y, {
|
||||
clickCount: request.params.dblClick ? 2 : 1,
|
||||
});
|
||||
@@ -164,6 +169,7 @@ export const clickAt = definePageTool({
|
||||
? `Successfully double clicked at the coordinates`
|
||||
: `Successfully clicked at the coordinates`,
|
||||
);
|
||||
appendWaitForResult(response, result);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
@@ -190,10 +196,11 @@ export const hover = definePageTool({
|
||||
const uid = request.params.uid;
|
||||
const handle = await request.page.getElementByUid(uid);
|
||||
try {
|
||||
await request.page.waitForEventsAfterAction(async () => {
|
||||
const result = await request.page.waitForEventsAfterAction(async () => {
|
||||
await handle.asLocator().hover();
|
||||
});
|
||||
response.appendResponseLine(`Successfully hovered over the element`);
|
||||
appendWaitForResult(response, result);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
@@ -314,7 +321,7 @@ export const fill = definePageTool({
|
||||
blockedByDialog: true,
|
||||
handler: async (request, response, context) => {
|
||||
const page = request.page;
|
||||
await page.waitForEventsAfterAction(async () => {
|
||||
const result = await page.waitForEventsAfterAction(async () => {
|
||||
await fillFormElement(
|
||||
request.params.uid,
|
||||
request.params.value,
|
||||
@@ -323,6 +330,7 @@ export const fill = definePageTool({
|
||||
);
|
||||
});
|
||||
response.appendResponseLine(`Successfully filled out the element`);
|
||||
appendWaitForResult(response, result);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
@@ -343,7 +351,7 @@ export const typeText = definePageTool({
|
||||
blockedByDialog: true,
|
||||
handler: async (request, response) => {
|
||||
const page = request.page;
|
||||
await page.waitForEventsAfterAction(async () => {
|
||||
const result = await page.waitForEventsAfterAction(async () => {
|
||||
await page.pptrPage.keyboard.type(request.params.text);
|
||||
if (request.params.submitKey) {
|
||||
await page.pptrPage.keyboard.press(
|
||||
@@ -354,6 +362,7 @@ export const typeText = definePageTool({
|
||||
response.appendResponseLine(
|
||||
`Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`,
|
||||
);
|
||||
appendWaitForResult(response, result);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -376,12 +385,13 @@ export const drag = definePageTool({
|
||||
);
|
||||
const toHandle = await request.page.getElementByUid(request.params.to_uid);
|
||||
try {
|
||||
await request.page.waitForEventsAfterAction(async () => {
|
||||
const result = await request.page.waitForEventsAfterAction(async () => {
|
||||
await fromHandle.drag(toHandle);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await toHandle.drop(fromHandle);
|
||||
});
|
||||
response.appendResponseLine(`Successfully dragged an element`);
|
||||
appendWaitForResult(response, result);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
@@ -418,8 +428,9 @@ export const fillForm = definePageTool({
|
||||
blockedByDialog: true,
|
||||
handler: async (request, response, context) => {
|
||||
const page = request.page;
|
||||
let lastResult: WaitForEventsResult = {};
|
||||
for (const element of request.params.elements) {
|
||||
await page.waitForEventsAfterAction(async () => {
|
||||
lastResult = await page.waitForEventsAfterAction(async () => {
|
||||
await fillFormElement(
|
||||
element.uid,
|
||||
element.value,
|
||||
@@ -429,6 +440,7 @@ export const fillForm = definePageTool({
|
||||
});
|
||||
}
|
||||
response.appendResponseLine(`Successfully filled out the form`);
|
||||
appendWaitForResult(response, lastResult);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
@@ -508,7 +520,7 @@ export const pressKey = definePageTool({
|
||||
const tokens = parseKey(request.params.key);
|
||||
const [key, ...modifiers] = tokens;
|
||||
|
||||
await page.waitForEventsAfterAction(async () => {
|
||||
const result = await page.waitForEventsAfterAction(async () => {
|
||||
for (const modifier of modifiers) {
|
||||
await page.pptrPage.keyboard.down(modifier);
|
||||
}
|
||||
@@ -521,6 +533,7 @@ export const pressKey = definePageTool({
|
||||
response.appendResponseLine(
|
||||
`Successfully pressed key: ${request.params.key}`,
|
||||
);
|
||||
appendWaitForResult(response, result);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
|
||||
+12
-7
@@ -7,6 +7,7 @@
|
||||
import {zod} from '../third_party/index.js';
|
||||
import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js';
|
||||
import type {ExtensionServiceWorker} from '../types.js';
|
||||
import {appendWaitForResult} from '../WaitForHelper.js';
|
||||
|
||||
import {ToolCategory} from './categories.js';
|
||||
import type {Context, Response} from './ToolDefinition.js';
|
||||
@@ -85,12 +86,15 @@ Example with arguments: \`(el) => {
|
||||
}
|
||||
|
||||
const worker = await getWebWorker(context, serviceWorkerId);
|
||||
await context.getSelectedMcpPage().waitForEventsAfterAction(
|
||||
async () => {
|
||||
await performEvaluation(worker, fnString, [], response);
|
||||
},
|
||||
{handleDialog: dialogAction ?? 'accept'},
|
||||
);
|
||||
const result = await context
|
||||
.getSelectedMcpPage()
|
||||
.waitForEventsAfterAction(
|
||||
async () => {
|
||||
await performEvaluation(worker, fnString, [], response);
|
||||
},
|
||||
{handleDialog: dialogAction ?? 'accept'},
|
||||
);
|
||||
appendWaitForResult(response, result);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,12 +114,13 @@ Example with arguments: \`(el) => {
|
||||
|
||||
const evaluatable = await getPageOrFrame(page, frames);
|
||||
|
||||
await mcpPage.waitForEventsAfterAction(
|
||||
const result = await mcpPage.waitForEventsAfterAction(
|
||||
async () => {
|
||||
await performEvaluation(evaluatable, fnString, args, response);
|
||||
},
|
||||
{handleDialog: dialogAction ?? 'accept'},
|
||||
);
|
||||
appendWaitForResult(response, result);
|
||||
} finally {
|
||||
void Promise.allSettled(args.map(arg => arg.dispose()));
|
||||
}
|
||||
|
||||
@@ -130,6 +130,67 @@ describe('input', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the new URL when click triggers a navigation', async () => {
|
||||
server.addHtmlRoute(
|
||||
'/start',
|
||||
html`<a href="/after-click">Navigate page</a>`,
|
||||
);
|
||||
server.addHtmlRoute('/after-click', html`<main>arrived</main>`);
|
||||
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedPptrPage();
|
||||
await page.goto(server.getRoute('/start'));
|
||||
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
await click.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
const expectedUrl = server.getRoute('/after-click');
|
||||
assert.ok(
|
||||
response.responseLines.some(
|
||||
line => line === `Page navigated to ${expectedUrl}.`,
|
||||
),
|
||||
`Expected response to mention navigation to ${expectedUrl}, got: ${response.responseLines.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not report navigation when click does not navigate', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedPptrPage();
|
||||
await page.setContent(
|
||||
html`<button onclick="this.innerText = 'clicked';">test</button>`,
|
||||
);
|
||||
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
await click.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
assert.ok(
|
||||
!response.responseLines.some(line =>
|
||||
line.startsWith('Page navigated to '),
|
||||
),
|
||||
`Did not expect a navigation line, got: ${response.responseLines.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for stable DOM', async () => {
|
||||
server.addHtmlRoute(
|
||||
'/unstable',
|
||||
|
||||
Reference in New Issue
Block a user