From 3fe3af6ac1bab599288237c681a76634471a520d Mon Sep 17 00:00:00 2001 From: bcfmtolgahan Date: Tue, 31 Mar 2026 17:23:57 +0300 Subject: [PATCH 01/10] refactor: move getNetworkMultiplierFromString to WaitForHelper --- src/McpContext.ts | 19 +------------------ src/WaitForHelper.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index 3ebbeae95..b76f96dc9 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -47,7 +47,7 @@ import { type InstalledExtension, } from './utils/ExtensionRegistry.js'; import {saveTemporaryFile} from './utils/files.js'; -import {WaitForHelper} from './WaitForHelper.js'; +import {getNetworkMultiplierFromString, WaitForHelper} from './WaitForHelper.js'; interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. @@ -61,23 +61,6 @@ interface McpContextOptions { const DEFAULT_TIMEOUT = 5_000; const NAVIGATION_TIMEOUT = 10_000; -function getNetworkMultiplierFromString(condition: string | null): number { - const puppeteerCondition = - condition as keyof typeof PredefinedNetworkConditions; - - switch (puppeteerCondition) { - case 'Fast 4G': - return 1; - case 'Slow 4G': - return 2.5; - case 'Fast 3G': - return 5; - case 'Slow 3G': - return 10; - } - return 1; -} - export class McpContext implements Context { browser: Browser; logger: Debugger; diff --git a/src/WaitForHelper.ts b/src/WaitForHelper.ts index 6c84daf12..b0137901e 100644 --- a/src/WaitForHelper.ts +++ b/src/WaitForHelper.ts @@ -6,6 +6,7 @@ import {logger} from './logger.js'; import type {Page, Protocol, CdpPage} from './third_party/index.js'; +import {PredefinedNetworkConditions} from './third_party/index.js'; export class WaitForHelper { #abortController = new AbortController(); @@ -160,3 +161,22 @@ export class WaitForHelper { } } } + +export function getNetworkMultiplierFromString( + condition: string | null, +): number { + const puppeteerCondition = + condition as keyof typeof PredefinedNetworkConditions; + + switch (puppeteerCondition) { + case 'Fast 4G': + return 1; + case 'Slow 4G': + return 2.5; + case 'Fast 3G': + return 5; + case 'Slow 3G': + return 10; + } + return 1; +} From 5c78734addb7f4f084a07de6eff9e40a2d52a0f7 Mon Sep 17 00:00:00 2001 From: bcfmtolgahan Date: Tue, 31 Mar 2026 17:48:48 +0300 Subject: [PATCH 02/10] feat: add waitForEventsAfterAction to McpPage --- src/McpPage.ts | 22 ++++++++++++++++++++++ tests/McpContext.test.ts | 6 +++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/McpPage.ts b/src/McpPage.ts index 73717464b..854a67fc1 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -18,6 +18,10 @@ import type { TextSnapshot, TextSnapshotNode, } from './types.js'; +import { + getNetworkMultiplierFromString, + WaitForHelper, +} from './WaitForHelper.js'; /** * Per-page state wrapper. Consolidates dialog, snapshot, emulation, @@ -91,6 +95,24 @@ export class McpPage implements ContextPage { return this.emulationSettings.colorScheme ?? null; } + createWaitForHelper( + cpuMultiplier: number, + networkMultiplier: number, + ): WaitForHelper { + return new WaitForHelper(this.pptrPage, cpuMultiplier, networkMultiplier); + } + + waitForEventsAfterAction( + action: () => Promise, + options?: {timeout?: number}, + ): Promise { + const helper = this.createWaitForHelper( + this.cpuThrottlingRate, + getNetworkMultiplierFromString(this.networkConditions), + ); + return helper.waitForEventsAfterAction(action, options); + } + dispose(): void { this.pptrPage.off('dialog', this.#dialogHandler); } diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 075c94742..d84b506cb 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -75,13 +75,13 @@ describe('McpContext', () => { cpuThrottlingRate: 2, networkConditions: 'Slow 3G', }); - const stub = sinon.spy(context, 'getWaitForHelper'); + const stub = sinon.spy(page, 'createWaitForHelper'); - await context.waitForEventsAfterAction(async () => { + await page.waitForEventsAfterAction(async () => { // trigger the waiting only }); - sinon.assert.calledWithExactly(stub, page.pptrPage, 2, 10); + sinon.assert.calledWithExactly(stub, 2, 10); }); }); From a7e1f202047c650313d12249a160f94fcf86583a Mon Sep 17 00:00:00 2001 From: bcfmtolgahan Date: Tue, 31 Mar 2026 18:12:51 +0300 Subject: [PATCH 03/10] refactor: move waitForEventsAfterAction from Context to ContextPage interface --- src/tools/ToolDefinition.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 9a0ef4355..ae4bf0be3 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -168,10 +168,6 @@ export type Context = Readonly<{ data: Uint8Array, filename: string, ): Promise<{filename: string}>; - waitForEventsAfterAction( - action: () => Promise, - options?: {timeout?: number}, - ): Promise; waitForTextOnPage( text: string[], timeout?: number, @@ -208,6 +204,10 @@ export type ContextPage = Readonly<{ getDialog(): Dialog | undefined; clearDialog(): void; + waitForEventsAfterAction( + action: () => Promise, + options?: {timeout?: number}, + ): Promise; }>; export function defineTool( From 996f442b659a05be55f9b6767785c96ea87142c8 Mon Sep 17 00:00:00 2001 From: bcfmtolgahan Date: Tue, 31 Mar 2026 18:20:12 +0300 Subject: [PATCH 04/10] refactor: move waitForEventsAfterAction from McpContext to McpPage, update all callers --- src/McpContext.ts | 27 +-------------------------- src/tools/input.ts | 28 ++++++++++++++-------------- src/tools/pages.ts | 4 ++-- src/tools/script.ts | 10 +++++----- 4 files changed, 22 insertions(+), 47 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index b76f96dc9..32b7413e6 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -47,7 +47,7 @@ import { type InstalledExtension, } from './utils/ExtensionRegistry.js'; import {saveTemporaryFile} from './utils/files.js'; -import {getNetworkMultiplierFromString, WaitForHelper} from './WaitForHelper.js'; +import {getNetworkMultiplierFromString} from './WaitForHelper.js'; interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. @@ -824,31 +824,6 @@ export class McpContext implements Context { return this.#traceResults; } - getWaitForHelper( - page: Page, - cpuMultiplier: number, - networkMultiplier: number, - ) { - return new WaitForHelper(page, cpuMultiplier, networkMultiplier); - } - - waitForEventsAfterAction( - action: () => Promise, - options?: {timeout?: number}, - ): Promise { - const page = this.#getSelectedMcpPage(); - const cpuMultiplier = page.cpuThrottlingRate; - const networkMultiplier = getNetworkMultiplierFromString( - page.networkConditions, - ); - const waitForHelper = this.getWaitForHelper( - page.pptrPage, - cpuMultiplier, - networkMultiplier, - ); - return waitForHelper.waitForEventsAfterAction(action, options); - } - getNetworkRequestStableId(request: HTTPRequest): number { return this.#networkCollector.getIdForResource(request); } diff --git a/src/tools/input.ts b/src/tools/input.ts index 492d55378..c377f9f35 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -58,11 +58,11 @@ export const click = definePageTool({ dblClick: dblClickSchema, includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { + handler: async (request, response, _context) => { const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); try { - await context.waitForEventsAfterAction(async () => { + await request.page.waitForEventsAfterAction(async () => { await handle.asLocator().click({ count: request.params.dblClick ? 2 : 1, }); @@ -97,9 +97,9 @@ export const clickAt = definePageTool({ dblClick: dblClickSchema, includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { + handler: async (request, response) => { const page = request.page; - await context.waitForEventsAfterAction(async () => { + await page.waitForEventsAfterAction(async () => { await page.pptrPage.mouse.click(request.params.x, request.params.y, { clickCount: request.params.dblClick ? 2 : 1, }); @@ -130,11 +130,11 @@ export const hover = definePageTool({ ), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { + handler: async (request, response, _context) => { const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); try { - await context.waitForEventsAfterAction(async () => { + await request.page.waitForEventsAfterAction(async () => { await handle.asLocator().hover(); }); response.appendResponseLine(`Successfully hovered over the element`); @@ -235,7 +235,7 @@ export const fill = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; - await context.waitForEventsAfterAction(async () => { + await page.waitForEventsAfterAction(async () => { await fillFormElement( request.params.uid, request.params.value, @@ -261,9 +261,9 @@ export const typeText = definePageTool({ text: zod.string().describe('The text to type'), submitKey: submitKeySchema, }, - handler: async (request, response, context) => { + handler: async (request, response) => { const page = request.page; - await context.waitForEventsAfterAction(async () => { + await page.waitForEventsAfterAction(async () => { await page.pptrPage.keyboard.type(request.params.text); if (request.params.submitKey) { await page.pptrPage.keyboard.press( @@ -289,13 +289,13 @@ export const drag = definePageTool({ to_uid: zod.string().describe('The uid of the element to drop into'), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { + handler: async (request, response) => { const fromHandle = await request.page.getElementByUid( request.params.from_uid, ); const toHandle = await request.page.getElementByUid(request.params.to_uid); try { - await context.waitForEventsAfterAction(async () => { + await request.page.waitForEventsAfterAction(async () => { await fromHandle.drag(toHandle); await new Promise(resolve => setTimeout(resolve, 50)); await toHandle.drop(fromHandle); @@ -332,7 +332,7 @@ export const fillForm = definePageTool({ handler: async (request, response, context) => { const page = request.page; for (const element of request.params.elements) { - await context.waitForEventsAfterAction(async () => { + await page.waitForEventsAfterAction(async () => { await fillFormElement( element.uid, element.value, @@ -413,12 +413,12 @@ export const pressKey = definePageTool({ ), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { + handler: async (request, response) => { const page = request.page; const tokens = parseKey(request.params.key); const [key, ...modifiers] = tokens; - await context.waitForEventsAfterAction(async () => { + await page.waitForEventsAfterAction(async () => { for (const modifier of modifiers) { await page.pptrPage.keyboard.down(modifier); } diff --git a/src/tools/pages.ts b/src/tools/pages.ts index d158c1c7b..982c01c52 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -119,7 +119,7 @@ export const newPage = defineTool({ request.params.isolatedContext, ); - await context.waitForEventsAfterAction( + await page.waitForEventsAfterAction( async () => { await page.pptrPage.goto(request.params.url, { timeout: request.params.timeout, @@ -206,7 +206,7 @@ export const navigatePage = definePageTool({ page.pptrPage.on('dialog', dialogHandler); try { - await context.waitForEventsAfterAction( + await page.waitForEventsAfterAction( async () => { switch (request.params.type) { case 'url': diff --git a/src/tools/script.ts b/src/tools/script.ts index e3ff14d7d..c461c7169 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -9,7 +9,7 @@ import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js'; import type {ExtensionServiceWorker} from '../types.js'; import {ToolCategory} from './categories.js'; -import type {Context, Response} from './ToolDefinition.js'; +import type {Context, ContextPage, Response} from './ToolDefinition.js'; import {defineTool, pageIdSchema} from './ToolDefinition.js'; export type Evaluatable = Page | Frame | WebWorker; @@ -77,7 +77,7 @@ Example with arguments: \`(el) => { } const worker = await getWebWorker(context, serviceWorkerId); - await performEvaluation(worker, fnString, [], response, context); + await performEvaluation(worker, fnString, [], response, context.getSelectedMcpPage()); return; } @@ -97,7 +97,7 @@ Example with arguments: \`(el) => { const evaluatable = await getPageOrFrame(page, frames); - await performEvaluation(evaluatable, fnString, args, response, context); + await performEvaluation(evaluatable, fnString, args, response, mcpPage); } finally { void Promise.allSettled(args.map(arg => arg.dispose())); } @@ -110,11 +110,11 @@ const performEvaluation = async ( fnString: string, args: Array>, response: Response, - context: Context, + page: ContextPage, ) => { const fn = await evaluatable.evaluateHandle(`(${fnString})`); try { - await context.waitForEventsAfterAction(async () => { + await page.waitForEventsAfterAction(async () => { const result = await evaluatable.evaluate( async (fn, ...args) => { // @ts-expect-error no types for function fn From 9b8830baed9a63176809f9193131f1826f27f029 Mon Sep 17 00:00:00 2001 From: bcfmtolgahan Date: Tue, 31 Mar 2026 18:25:51 +0300 Subject: [PATCH 05/10] refactor: drop unused context parameter in click and hover handlers --- src/tools/input.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/input.ts b/src/tools/input.ts index c377f9f35..0ff761398 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -58,7 +58,7 @@ export const click = definePageTool({ dblClick: dblClickSchema, includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, _context) => { + handler: async (request, response) => { const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); try { @@ -130,7 +130,7 @@ export const hover = definePageTool({ ), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, _context) => { + handler: async (request, response) => { const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); try { From 942eb5fc2156500de2c6b53906602756df2d919f Mon Sep 17 00:00:00 2001 From: bcfmtolgahan Date: Wed, 1 Apr 2026 10:46:57 +0300 Subject: [PATCH 06/10] refactor: extract waitForEventsAfterAction out of performEvaluation Service worker evaluation should not trigger DOM stability wait since service workers don't interact with the page DOM. Move the waitForEventsAfterAction call to the normal page evaluation path only, removing the page parameter from performEvaluation. --- src/tools/script.ts | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/tools/script.ts b/src/tools/script.ts index c461c7169..932b7b0d6 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -9,7 +9,7 @@ import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js'; import type {ExtensionServiceWorker} from '../types.js'; import {ToolCategory} from './categories.js'; -import type {Context, ContextPage, Response} from './ToolDefinition.js'; +import type {Context, Response} from './ToolDefinition.js'; import {defineTool, pageIdSchema} from './ToolDefinition.js'; export type Evaluatable = Page | Frame | WebWorker; @@ -77,7 +77,7 @@ Example with arguments: \`(el) => { } const worker = await getWebWorker(context, serviceWorkerId); - await performEvaluation(worker, fnString, [], response, context.getSelectedMcpPage()); + await performEvaluation(worker, fnString, [], response); return; } @@ -97,7 +97,9 @@ Example with arguments: \`(el) => { const evaluatable = await getPageOrFrame(page, frames); - await performEvaluation(evaluatable, fnString, args, response, mcpPage); + await mcpPage.waitForEventsAfterAction(async () => { + await performEvaluation(evaluatable, fnString, args, response); + }); } finally { void Promise.allSettled(args.map(arg => arg.dispose())); } @@ -110,24 +112,21 @@ const performEvaluation = async ( fnString: string, args: Array>, response: Response, - page: ContextPage, ) => { const fn = await evaluatable.evaluateHandle(`(${fnString})`); try { - await page.waitForEventsAfterAction(async () => { - const result = await evaluatable.evaluate( - async (fn, ...args) => { - // @ts-expect-error no types for function fn - return JSON.stringify(await fn(...args)); - }, - fn, - ...args, - ); - response.appendResponseLine('Script ran on page and returned:'); - response.appendResponseLine('```json'); - response.appendResponseLine(`${result}`); - response.appendResponseLine('```'); - }); + const result = await evaluatable.evaluate( + async (fn, ...args) => { + // @ts-expect-error no types for function fn + return JSON.stringify(await fn(...args)); + }, + fn, + ...args, + ); + response.appendResponseLine('Script ran on page and returned:'); + response.appendResponseLine('```json'); + response.appendResponseLine(`${result}`); + response.appendResponseLine('```'); } finally { void fn.dispose(); } From e2ebd9ec9364de60fe530b4c2faf9d9ecd1959d6 Mon Sep 17 00:00:00 2001 From: bcfmtolgahan Date: Wed, 1 Apr 2026 10:59:29 +0300 Subject: [PATCH 07/10] chore: remove debug comment, document createWaitForHelper visibility --- src/McpPage.ts | 1 + src/tools/input.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/McpPage.ts b/src/McpPage.ts index 854a67fc1..e65c78dfa 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -95,6 +95,7 @@ export class McpPage implements ContextPage { return this.emulationSettings.colorScheme ?? null; } + // Public for testability: tests spy on this method to verify throttle multipliers. createWaitForHelper( cpuMultiplier: number, networkMultiplier: number, diff --git a/src/tools/input.ts b/src/tools/input.ts index 02c4d80b7..0ff761398 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -217,7 +217,6 @@ async function fillFormElement( } } -// here export const fill = definePageTool({ name: 'fill', description: `Type text into a input, text area or select an option from a