From 5397c2fc4242ff1ab3561afd529e997cb03045a9 Mon Sep 17 00:00:00 2001 From: Thomas Bachem Date: Fri, 10 Jul 2026 06:53:05 +0100 Subject: [PATCH] feat: allow evaluate_script to skip the stable DOM wait --- docs/tool-reference.md | 1 + src/McpPage.ts | 1 + src/WaitForHelper.ts | 5 ++- src/bin/chrome-devtools-cli-options.ts | 7 ++++ src/telemetry/tool_call_metrics.json | 4 ++ src/tools/ToolDefinition.ts | 1 + src/tools/script.ts | 12 +++++- tests/tools/script.test.ts | 54 ++++++++++++++++++++++++++ 8 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index ae520fb7e..b655a5cd2 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -358,6 +358,7 @@ - **args** (array) _(optional)_: An optional list of arguments to pass to the function. - **dialogAction** (string) _(optional)_: Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept. - **filePath** (string) _(optional)_: The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline. +- **waitForStableDom** (boolean) _(optional)_: Whether to wait for the DOM to settle. Pass false if the script only reads data. Defaults to true. --- diff --git a/src/McpPage.ts b/src/McpPage.ts index fe9a0f813..0cbdcb12a 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -261,6 +261,7 @@ export class McpPage implements ContextPage { action: () => Promise, options?: { timeout?: number; + waitForStableDom?: boolean; handleDialog?: DialogAction | Partial>; }, diff --git a/src/WaitForHelper.ts b/src/WaitForHelper.ts index 899ebe7fa..04d77cd50 100644 --- a/src/WaitForHelper.ts +++ b/src/WaitForHelper.ts @@ -134,6 +134,7 @@ export class WaitForHelper { action: () => Promise, options?: { timeout?: number; + waitForStableDom?: boolean; handleDialog?: DialogAction | Partial>; }, @@ -199,7 +200,9 @@ export class WaitForHelper { // Wait for stable dom after navigation so we execute in // the correct context - await this.waitForStableDom(); + if (options?.waitForStableDom !== false) { + await this.waitForStableDom(); + } } catch (error) { logger?.(error); } finally { diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index cb0f560c7..69607ed66 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -249,6 +249,13 @@ export const commands: Commands = { 'Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.', required: false, }, + waitForStableDom: { + name: 'waitForStableDom', + type: 'boolean', + description: + 'Whether to wait for the DOM to settle. Pass false if the script only reads data. Defaults to true.', + required: false, + }, }, }, execute_3p_developer_tool: { diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 6b19f8953..195a879dd 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -119,6 +119,10 @@ { "name": "file_path_length", "argType": "number" + }, + { + "name": "wait_for_stable_dom", + "argType": "boolean" } ] }, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 06e7c36c4..f934676ff 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -301,6 +301,7 @@ export type ContextPage = Readonly<{ action: () => Promise, options?: { timeout?: number; + waitForStableDom?: boolean; handleDialog?: DialogAction | Partial>; }, diff --git a/src/tools/script.ts b/src/tools/script.ts index 022409e9c..14e1f64f5 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -52,6 +52,12 @@ Example with arguments: \`(el) => el.innerText\` .describe( 'Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.', ), + waitForStableDom: zod + .boolean() + .optional() + .describe( + 'Whether to wait for the DOM to settle. Pass false if the script only reads data. Defaults to true.', + ), ...(cliArgs?.categoryExtensions ? { serviceWorkerId: zod @@ -73,6 +79,7 @@ Example with arguments: \`(el) => el.innerText\` pageId, dialogAction, filePath, + waitForStableDom, } = request.params; if (cliArgs?.categoryExtensions && serviceWorkerId) { @@ -95,7 +102,8 @@ Example with arguments: \`(el) => el.innerText\` context, }); }, - {handleDialog: dialogAction ?? 'accept'}, + // Service workers cannot interact with the DOM, so never wait for it. + {handleDialog: dialogAction ?? 'accept', waitForStableDom: false}, ); if (result.dialogHandled) { context.getSelectedMcpPage().clearDialog(); @@ -127,7 +135,7 @@ Example with arguments: \`(el) => el.innerText\` context, }); }, - {handleDialog: dialogAction ?? 'accept'}, + {handleDialog: dialogAction ?? 'accept', waitForStableDom}, ); response.attachWaitForResult(result); } finally { diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index c2883ab70..1f76862aa 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -8,14 +8,18 @@ import assert from 'node:assert'; import path from 'node:path'; import {describe, it} from 'node:test'; +import sinon from 'sinon'; + import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; import {installExtension} from '../../src/tools/extensions.js'; import {evaluateScript} from '../../src/tools/script.js'; +import {WaitForHelper} from '../../src/WaitForHelper.js'; import {serverHooks} from '../server.js'; import { assertNoServiceWorkerReported, extractExtensionId, + getTextContent, html, withMcpContext, } from '../utils.js'; @@ -42,6 +46,56 @@ describe('script', () => { assert.strictEqual(JSON.parse(lineEvaluation), 10); }); }); + it('skips the stable DOM wait when waitForStableDom is false', async () => { + await withMcpContext(async (response, context) => { + const spy = sinon.spy(WaitForHelper.prototype, 'waitForStableDom'); + try { + await evaluateScript().handler( + { + params: {function: String(() => 1), waitForStableDom: false}, + }, + response, + context, + ); + sinon.assert.notCalled(spy); + + await evaluateScript().handler( + { + params: {function: String(() => 1)}, + }, + response, + context, + ); + sinon.assert.calledOnce(spy); + } finally { + spy.restore(); + } + }); + }); + it('still awaits a navigation when waitForStableDom is false', async () => { + await withMcpContext(async (response, context) => { + server.addHtmlRoute('/nav-target', html`
navigated
`); + const url = server.getRoute('/nav-target'); + await evaluateScript().handler( + { + params: { + function: `() => { + location.href = '${url}'; + }`, + waitForStableDom: false, + }, + }, + response, + context, + ); + const result = await response.handle('test', context); + const textContent = getTextContent(result.content[0]); + assert.ok( + textContent.includes(`Page navigated to ${url}`), + `Expected the navigation to be awaited and reported, got: ${textContent}`, + ); + }); + }); it('runs in selected page', async () => { await withMcpContext(async (response, context) => { await evaluateScript().handler(