diff --git a/src/McpPage.ts b/src/McpPage.ts index 485e43006..a7ffd76e4 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -121,15 +121,21 @@ export class McpPage implements ContextPage { return new WaitForHelper(this.pptrPage, cpuMultiplier, networkMultiplier); } - waitForEventsAfterAction( + async waitForEventsAfterAction( action: () => Promise, options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string}, - ): Promise { + ): Promise<{navigatedToUrl?: string}> { + const urlBefore = this.pptrPage.url(); const helper = this.createWaitForHelper( this.cpuThrottlingRate, getNetworkMultiplierFromString(this.networkConditions), ); - return helper.waitForEventsAfterAction(action, options); + await helper.waitForEventsAfterAction(action, options); + const urlAfter = this.pptrPage.url(); + if (urlAfter !== urlBefore) { + return {navigatedToUrl: urlAfter}; + } + return {}; } dispose(): void { diff --git a/src/WaitForHelper.ts b/src/WaitForHelper.ts index f41ca84cc..e34bd0ce4 100644 --- a/src/WaitForHelper.ts +++ b/src/WaitForHelper.ts @@ -196,3 +196,12 @@ export function getNetworkMultiplierFromString( } return 1; } + +export function appendNavigatedToUrl( + response: {appendResponseLine(value: string): void}, + result: {navigatedToUrl?: string}, +): void { + if (result.navigatedToUrl) { + response.appendResponseLine(`Navigated to ${result.navigatedToUrl}`); + } +} diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 4ff48ecf1..665926ee3 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -254,7 +254,7 @@ export type ContextPage = Readonly<{ waitForEventsAfterAction( action: () => Promise, options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string}, - ): Promise; + ): Promise<{navigatedToUrl?: string}>; getInPageTools(): ToolGroup | undefined; executeInPageTool( toolName: string, diff --git a/src/tools/input.ts b/src/tools/input.ts index bce93f73b..bfaf6f2ed 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -10,6 +10,7 @@ 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 {appendNavigatedToUrl} from '../WaitForHelper.js'; import {ToolCategory} from './categories.js'; import type {ContextPage} from './ToolDefinition.js'; @@ -62,7 +63,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 +73,7 @@ export const click = definePageTool({ ? `Successfully double clicked on the element` : `Successfully clicked on the element`, ); + appendNavigatedToUrl(response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -99,7 +101,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 +111,7 @@ export const clickAt = definePageTool({ ? `Successfully double clicked at the coordinates` : `Successfully clicked at the coordinates`, ); + appendNavigatedToUrl(response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -134,10 +137,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`); + appendNavigatedToUrl(response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -235,7 +239,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 +248,7 @@ export const fill = definePageTool({ ); }); response.appendResponseLine(`Successfully filled out the element`); + appendNavigatedToUrl(response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -263,7 +268,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 +279,7 @@ export const typeText = definePageTool({ response.appendResponseLine( `Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`, ); + appendNavigatedToUrl(response, result); }, }); @@ -295,12 +301,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`); + appendNavigatedToUrl(response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -332,8 +339,9 @@ export const fillForm = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; + let lastResult: {navigatedToUrl?: string} = {}; for (const element of request.params.elements) { - await page.waitForEventsAfterAction(async () => { + const result = await page.waitForEventsAfterAction(async () => { await fillFormElement( element.uid, element.value, @@ -341,8 +349,12 @@ export const fillForm = definePageTool({ page, ); }); + if (result.navigatedToUrl) { + lastResult = result; + } } response.appendResponseLine(`Successfully filled out the form`); + appendNavigatedToUrl(response, lastResult); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -419,7 +431,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 +444,7 @@ export const pressKey = definePageTool({ response.appendResponseLine( `Successfully pressed key: ${request.params.key}`, ); + appendNavigatedToUrl(response, result); if (request.params.includeSnapshot) { response.includeSnapshot(); } diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 92fe6afcd..fea80f9ba 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -201,6 +201,9 @@ export const newPage = defineTool(args => { request.params.timeout, ); + response.appendResponseLine( + `Successfully navigated to ${page.pptrPage.url()}.`, + ); response.setIncludePages(true); response.setListInPageTools(); }, @@ -303,8 +306,9 @@ export const navigatePage = definePageTool(args => { } try { await page.pptrPage.goto(request.params.url, options); + const finalUrl = page.pptrPage.url(); response.appendResponseLine( - `Successfully navigated to ${request.params.url}.`, + `Successfully navigated to ${finalUrl}.`, ); } catch (error) { response.appendResponseLine( diff --git a/src/tools/script.ts b/src/tools/script.ts index e91e3b8de..59244c481 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -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 {appendNavigatedToUrl} from '../WaitForHelper.js'; import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; @@ -84,12 +85,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'}, + ); + appendNavigatedToUrl(response, result); return; } @@ -109,12 +113,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'}, ); + appendNavigatedToUrl(response, result); } finally { void Promise.allSettled(args.map(arg => arg.dispose())); } diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index b0033ec4d..f4a819bd9 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -130,6 +130,64 @@ describe('input', () => { }); }); + it('reports navigated URL after click', async () => { + server.addHtmlRoute('/nav-link', html`Go`); + server.addHtmlRoute('/nav-target', html`
Target
`); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.goto(server.getRoute('/nav-link')); + context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create( + context.getSelectedMcpPage(), + ); + await click.handler( + { + params: {uid: '1_1'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[0], + 'Successfully clicked on the element', + ); + assert.ok( + response.responseLines[1]?.startsWith('Navigated to '), + `Expected "Navigated to" but got: ${response.responseLines[1]}`, + ); + assert.ok( + response.responseLines[1]?.includes('/nav-target'), + `Expected URL to contain /nav-target but got: ${response.responseLines[1]}`, + ); + }); + }); + + it('does not report navigated URL when no navigation occurs', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent( + html``, + ); + context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create( + context.getSelectedMcpPage(), + ); + await click.handler( + { + params: {uid: '1_1'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + assert.strictEqual(response.responseLines.length, 1); + assert.strictEqual( + response.responseLines[0], + 'Successfully clicked on the element', + ); + }); + }); + it('waits for stable DOM', async () => { server.addHtmlRoute( '/unstable', diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index f1128afd2..14a52686f 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -535,6 +535,27 @@ describe('pages', () => { }); }); + it('reports final URL after navigation', async () => { + await withMcpContext(async (response, context) => { + await navigatePage().handler( + { + params: {url: 'data:text/html,
Hello
'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + assert.ok( + response.responseLines[0]?.startsWith('Successfully navigated to '), + `Expected "Successfully navigated to" but got: ${response.responseLines[0]}`, + ); + assert.ok( + response.responseLines[0]?.includes('data:text/html'), + `Expected URL in response but got: ${response.responseLines[0]}`, + ); + }); + }); + it('throws an error if the page was closed not by the MCP server', async () => { await withMcpContext(async (response, context) => { const page = await context.newPage();