diff --git a/src/McpPage.ts b/src/McpPage.ts index 1e311bc62..68ebe4b56 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -22,6 +22,7 @@ import type { import { getNetworkMultiplierFromString, WaitForHelper, + type WaitForEventsResult, } from './WaitForHelper.js'; /** @@ -113,7 +114,7 @@ export class McpPage implements ContextPage { waitForEventsAfterAction( action: () => Promise, options?: {timeout?: number}, - ): Promise { + ): Promise { const helper = this.createWaitForHelper( this.cpuThrottlingRate, getNetworkMultiplierFromString(this.networkConditions), diff --git a/src/WaitForHelper.ts b/src/WaitForHelper.ts index 2dd0f48c1..2d05f7853 100644 --- a/src/WaitForHelper.ts +++ b/src/WaitForHelper.ts @@ -127,10 +127,12 @@ export class WaitForHelper { async waitForEventsAfterAction( action: () => Promise, options?: {timeout?: number}, - ): Promise { + ): Promise { + let navigated = false; const navigationFinished = this.waitForNavigationStarted() .then(navigationStated => { if (navigationStated) { + navigated = true; return this.#page.waitForNavigation({ timeout: options?.timeout ?? this.#navigationTimeout, signal: this.#abortController.signal, @@ -159,9 +161,19 @@ export class WaitForHelper { } finally { this.#abortController.abort(); } + + return {navigated}; } } +export interface WaitForEventsResult { + /** + * Whether a cross-document navigation started and finished during the + * action. Same-document (history API) navigations are not reported. + */ + navigated: boolean; +} + export function getNetworkMultiplierFromString( condition: string | null, ): number { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 8001e18c7..163e5024f 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -22,6 +22,7 @@ import type { } from '../types.js'; import type {InstalledExtension} from '../utils/ExtensionRegistry.js'; import type {PaginationOptions} from '../utils/types.js'; +import type {WaitForEventsResult} from '../WaitForHelper.js'; import type {ToolCategory} from './categories.js'; import type { @@ -211,7 +212,7 @@ export type ContextPage = Readonly<{ waitForEventsAfterAction( action: () => Promise, options?: {timeout?: number}, - ): Promise; + ): Promise; getInPageTools(): ToolGroup | undefined; }>; diff --git a/src/tools/input.ts b/src/tools/input.ts index bce93f73b..067573b22 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -10,9 +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 type {WaitForEventsResult} from '../WaitForHelper.js'; import {ToolCategory} from './categories.js'; -import type {ContextPage} from './ToolDefinition.js'; +import type {ContextPage, Response} from './ToolDefinition.js'; import {definePageTool} from './ToolDefinition.js'; const dblClickSchema = zod @@ -42,6 +43,16 @@ function handleActionError(error: unknown, uid: string) { ); } +function appendNavigationIfAny( + page: ContextPage, + response: Response, + result: WaitForEventsResult, +) { + if (result.navigated) { + response.appendResponseLine(`Page navigated to ${page.pptrPage.url()}.`); + } +} + export const click = definePageTool({ name: 'click', description: `Clicks on the provided element`, @@ -62,7 +73,7 @@ export const click = 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().click({ count: request.params.dblClick ? 2 : 1, }); @@ -72,6 +83,7 @@ export const click = definePageTool({ ? `Successfully double clicked on the element` : `Successfully clicked on the element`, ); + appendNavigationIfAny(request.page, response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -99,7 +111,7 @@ export const clickAt = definePageTool({ }, 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, }); @@ -109,6 +121,7 @@ export const clickAt = definePageTool({ ? `Successfully double clicked at the coordinates` : `Successfully clicked at the coordinates`, ); + appendNavigationIfAny(page, response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -134,10 +147,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`); + appendNavigationIfAny(request.page, response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -235,7 +249,7 @@ export const fill = definePageTool({ }, 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, @@ -244,6 +258,7 @@ export const fill = definePageTool({ ); }); response.appendResponseLine(`Successfully filled out the element`); + appendNavigationIfAny(page, response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -263,7 +278,7 @@ export const typeText = definePageTool({ }, 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( @@ -274,6 +289,7 @@ export const typeText = definePageTool({ response.appendResponseLine( `Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`, ); + appendNavigationIfAny(page, response, result); }, }); @@ -295,12 +311,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`); + appendNavigationIfAny(request.page, response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -332,8 +349,9 @@ export const fillForm = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; + let lastResult: WaitForEventsResult = {navigated: false}; for (const element of request.params.elements) { - await page.waitForEventsAfterAction(async () => { + lastResult = await page.waitForEventsAfterAction(async () => { await fillFormElement( element.uid, element.value, @@ -343,6 +361,7 @@ export const fillForm = definePageTool({ }); } response.appendResponseLine(`Successfully filled out the form`); + appendNavigationIfAny(page, response, lastResult); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -419,7 +438,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); } @@ -432,6 +451,7 @@ export const pressKey = definePageTool({ response.appendResponseLine( `Successfully pressed key: ${request.params.key}`, ); + appendNavigationIfAny(page, response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } diff --git a/src/tools/script.ts b/src/tools/script.ts index 725628bcd..7439d5461 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -77,11 +77,15 @@ Example with arguments: \`(el) => { } const worker = await getWebWorker(context, serviceWorkerId); - await context - .getSelectedMcpPage() - .waitForEventsAfterAction(async () => { - await performEvaluation(worker, fnString, [], response); - }); + const selectedPage = context.getSelectedMcpPage(); + const result = await selectedPage.waitForEventsAfterAction(async () => { + await performEvaluation(worker, fnString, [], response); + }); + if (result.navigated) { + response.appendResponseLine( + `Page navigated to ${selectedPage.pptrPage.url()}.`, + ); + } return; } @@ -101,9 +105,12 @@ Example with arguments: \`(el) => { const evaluatable = await getPageOrFrame(page, frames); - await mcpPage.waitForEventsAfterAction(async () => { + const result = await mcpPage.waitForEventsAfterAction(async () => { await performEvaluation(evaluatable, fnString, args, response); }); + if (result.navigated) { + response.appendResponseLine(`Page navigated to ${page.url()}.`); + } } finally { void Promise.allSettled(args.map(arg => arg.dispose())); } diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 5590e88c1..16cc95114 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -123,6 +123,63 @@ describe('input', () => { }); }); + it('reports the new URL when click triggers a navigation', async () => { + server.addHtmlRoute( + '/start', + html`Navigate page`, + ); + server.addHtmlRoute('/after-click', html`
arrived
`); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.goto(server.getRoute('/start')); + await context.createTextSnapshot(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``, + ); + await context.createTextSnapshot(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',