diff --git a/docs/tool-reference.md b/docs/tool-reference.md index ae520fb7e..674f5509c 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 become stable after the script runs. Set to false for read-only scripts on pages with continuous DOM mutations. 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..37aac171a 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 become stable after the script runs. Set to false for read-only scripts on pages with continuous DOM mutations. 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..52cb79436 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 become stable after the script runs. Set to false for read-only scripts on pages with continuous DOM mutations. 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,10 @@ Example with arguments: \`(el) => el.innerText\` context, }); }, - {handleDialog: dialogAction ?? 'accept'}, + { + handleDialog: dialogAction ?? 'accept', + waitForStableDom, + }, ); if (result.dialogHandled) { context.getSelectedMcpPage().clearDialog(); @@ -127,7 +137,10 @@ 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..8e8737426 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -8,6 +8,8 @@ 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'; @@ -196,6 +198,40 @@ describe('script', () => { }); }); + it('can skip waiting for a stable DOM', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage(); + const waitForEventsAfterAction = sinon + .stub(page, 'waitForEventsAfterAction') + .callsFake(async action => { + await action(); + return {}; + }); + + try { + await evaluateScript().handler( + { + params: { + function: String(() => document.title), + waitForStableDom: false, + }, + }, + response, + context, + ); + } finally { + waitForEventsAfterAction.restore(); + } + + assert.strictEqual( + waitForEventsAfterAction.firstCall.args[1]?.waitForStableDom, + false, + ); + const lineEvaluation = response.responseLines.at(2)!; + assert.strictEqual(JSON.parse(lineEvaluation), ''); + }); + }); + it('work with one argument', async () => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage().pptrPage;