From 2616476cedb94c6ca9cf21039d68184d27b433f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=B9=E6=A2=A6=E8=BE=BE?= <1361371152@qq.com> Date: Sun, 23 Aug 2026 00:31:54 +0800 Subject: [PATCH] feat: support evaluating scripts from local files --- docs/tool-reference.md | 12 +- skills/chrome-devtools-cli/SKILL.md | 1 + src/bin/chrome-devtools.ts | 20 ++- src/config/cli-options.ts | 21 ++- src/telemetry/tool_call_metrics.json | 8 ++ src/tools/script.ts | 95 ++++++++++--- tests/e2e/chrome-devtools-commands.test.ts | 47 +++++++ tests/tools/script.test.ts | 155 +++++++++++++++++++-- 8 files changed, 322 insertions(+), 37 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index d6aa0f2b2..47b2df6ae 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -354,17 +354,19 @@ ### `evaluate_script` -**Description:** Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, so returned values have to be JSON-serializable. +**Description:** Evaluate JavaScript inside the currently selected page. The source can be provided inline or loaded from a local file. Returns the response as JSON, so returned values have to be JSON-serializable. **Parameters:** -- **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the currently selected page. - Example without arguments: `() => document.title` or `async () => await fetch("example.com")`. - Example with arguments: `(el) => el.innerText` - - **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. +- **format** (enum: "function", "script") _(optional)_: How to interpret the source. "function" treats it as a function declaration and supports args. "script" evaluates it as classic JavaScript and does not support args. Defaults to "function". ECMAScript modules are not supported. +- **function** (string) _(optional)_: JavaScript source to execute in the currently selected page. Provide either this or sourcePath, but not both. The source is interpreted according to format. + Example without arguments: `() => document.title` or `async () => await fetch("example.com")`. + Example with arguments: `(el) => el.innerText` + +- **sourcePath** (string) _(optional)_: The absolute or relative path to a JavaScript file on the MCP server's local filesystem. Provide either this or function, but not both. - **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/skills/chrome-devtools-cli/SKILL.md b/skills/chrome-devtools-cli/SKILL.md index 6b7749a34..c6d38d4dc 100644 --- a/skills/chrome-devtools-cli/SKILL.md +++ b/skills/chrome-devtools-cli/SKILL.md @@ -139,6 +139,7 @@ chrome-devtools list_network_requests --includePreservedRequests true # Include ```bash chrome-devtools evaluate_script "() => document.title" # Evaluate a JavaScript function on the page chrome-devtools evaluate_script "(a) => a.innerText" --args 1_4 # Evaluate JS with UID arguments +chrome-devtools evaluate_script --sourcePath ./script.js --format script # Evaluate a local classic JavaScript file chrome-devtools get_console_message 1 # Gets a console message by its ID chrome-devtools lighthouse_audit --mode "navigation" # Run Lighthouse audit for navigation chrome-devtools lighthouse_audit --mode "snapshot" --device "mobile" # Run Lighthouse audit for a snapshot on mobile diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 733b04191..cec3b8723 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -42,6 +42,7 @@ await checkForUpdates( ); const DEFAULT_CLI_ARGS = ['--viaCli']; +const OPTIONAL_POSITIONAL_ARGS = new Set(['evaluate_script:function']); async function start(args: string[], sessionId: string) { const combinedArgs = [...DEFAULT_CLI_ARGS, ...args]; @@ -109,7 +110,7 @@ const y = yargs(hideBin(process.argv)) ' - CORRECT: chrome-devtools evaluate_script "() => document.title"', ); console.error( - '2. Optional parameters are passed as double-dash options/flags (e.g. --pageId 1).', + '2. Optional parameters are passed as double-dash options/flags (e.g. --pageId 1), except optional positional parameters shown in command help.', ); console.error( '3. Make sure to escape quotes properly for your shell environment.', @@ -220,13 +221,23 @@ for (const [commandName, commandDef] of Object.entries(commands)) { ); const optionalArgNames = Object.keys(args).filter( - name => !args[name].required, + name => + !args[name].required && + !OPTIONAL_POSITIONAL_ARGS.has(`${commandName}:${name}`), + ); + const optionalPositionalArgNames = Object.keys(args).filter( + name => + !args[name].required && + OPTIONAL_POSITIONAL_ARGS.has(`${commandName}:${name}`), ); let commandStr = commandName; for (const arg of requiredArgNames) { commandStr += ` <${arg}>`; } + for (const arg of optionalPositionalArgNames) { + commandStr += ` [${arg}]`; + } for (const arg of optionalArgNames) { commandStr += ` [--${arg}]`; @@ -250,7 +261,10 @@ for (const [commandName, commandDef] of Object.entries(commands)) { ? 'array' : 'string'; - if (opt.required) { + if ( + opt.required || + OPTIONAL_POSITIONAL_ARGS.has(`${commandName}:${argName}`) + ) { const options: PositionalOptions = { describe: opt.description, type: type as PositionalOptions['type'], diff --git a/src/config/cli-options.ts b/src/config/cli-options.ts index cf97b0aa4..6ee8733ae 100644 --- a/src/config/cli-options.ts +++ b/src/config/cli-options.ts @@ -223,15 +223,30 @@ export const commands: Commands = { }, evaluate_script: { description: - 'Evaluate a JavaScript function inside the currently selected page or service worker. Returns the response as JSON, so returned values have to be JSON-serializable.', + 'Evaluate JavaScript inside the currently selected page or service worker. The source can be provided inline or loaded from a local file. Returns the response as JSON, so returned values have to be JSON-serializable.', category: 'Debugging', args: { function: { name: 'function', type: 'string', description: - 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => document.title` or `async () => await fetch("example.com")`.\nExample with arguments: `(el) => el.innerText`\n', - required: true, + 'JavaScript source to execute in the currently selected page. Provide either this or sourcePath, but not both. The source is interpreted according to format.\nExample without arguments: `() => document.title` or `async () => await fetch("example.com")`.\nExample with arguments: `(el) => el.innerText`\n', + required: false, + }, + sourcePath: { + name: 'sourcePath', + type: 'string', + description: + "The absolute or relative path to a JavaScript file on the MCP server's local filesystem. Provide either this or function, but not both.", + required: false, + }, + format: { + name: 'format', + type: 'string', + description: + 'How to interpret the source. "function" treats it as a function declaration and supports args. "script" evaluates it as classic JavaScript and does not support args. Defaults to "function". ECMAScript modules are not supported.', + required: false, + enum: ['function', 'script'], }, args: { name: 'args', diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index aa9bfe244..e399d97ed 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -123,6 +123,14 @@ { "name": "wait_for_stable_dom", "argType": "boolean" + }, + { + "name": "source_path_length", + "argType": "number" + }, + { + "name": "format", + "argType": "string" } ] }, diff --git a/src/tools/script.ts b/src/tools/script.ts index 41d37061b..da2960c27 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {readFile} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; + import {zod} from '../third_party/index.js'; import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js'; import type {ExtensionServiceWorker} from '../types.js'; @@ -17,19 +20,34 @@ export type Evaluatable = Page | Frame | WebWorker; export const evaluateScript = defineTool(cliArgs => { return { name: 'evaluate_script', - description: `Evaluate a JavaScript function inside the currently selected page${cliArgs?.categoryExtensions ? ' or service worker' : ''}. Returns the response as JSON, so returned values have to be JSON-serializable.`, + description: `Evaluate JavaScript inside the currently selected page${cliArgs?.categoryExtensions ? ' or service worker' : ''}. The source can be provided inline or loaded from a local file. Returns the response as JSON, so returned values have to be JSON-serializable.`, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, }, schema: { ...(cliArgs?.experimentalPageIdRouting ? pageIdSchema : {}), - function: zod.string().describe( - `A JavaScript function declaration to be executed by the tool in the currently selected page. + function: zod + .string() + .optional() + .describe( + `JavaScript source to execute in the currently selected page. Provide either this or sourcePath, but not both. The source is interpreted according to format. Example without arguments: \`() => document.title\` or \`async () => await fetch("example.com")\`. Example with arguments: \`(el) => el.innerText\` `, - ), + ), + sourcePath: zod + .string() + .optional() + .describe( + "The absolute or relative path to a JavaScript file on the MCP server's local filesystem. Provide either this or function, but not both.", + ), + format: zod + .enum(['function', 'script']) + .optional() + .describe( + 'How to interpret the source. "function" treats it as a function declaration and supports args. "script" evaluates it as classic JavaScript and does not support args. Defaults to "function". ECMAScript modules are not supported.', + ), args: zod .array( zod @@ -72,18 +90,26 @@ Example with arguments: \`(el) => el.innerText\` blockedByDialog: true, verifyFilesSchema: { filePath: true, + sourcePath: true, }, handler: async (request, response, context) => { const { serviceWorkerId, args: uidArgs, function: fnString, + sourcePath, + format = 'function', pageId, dialogAction, filePath, waitForStableDom, } = request.params; + const source = await resolveScriptSource(fnString, sourcePath); + if (format === 'script' && uidArgs && uidArgs.length > 0) { + throw new Error('args cannot be used when format is "script".'); + } + if (cliArgs?.categoryExtensions && serviceWorkerId) { if (uidArgs && uidArgs.length > 0) { throw new Error( @@ -99,7 +125,7 @@ Example with arguments: \`(el) => el.innerText\` .getSelectedMcpPage() .waitForEventsAfterAction( async () => { - await performEvaluation(worker, fnString, [], response, { + await performEvaluation(worker, source, format, [], response, { filePath, context, }); @@ -134,7 +160,7 @@ Example with arguments: \`(el) => el.innerText\` const result = await mcpPage.waitForEventsAfterAction( async () => { - await performEvaluation(evaluatable, fnString, args, response, { + await performEvaluation(evaluatable, source, format, args, response, { filePath, context, }); @@ -146,24 +172,57 @@ Example with arguments: \`(el) => el.innerText\` }; }); +const resolveScriptSource = async ( + inlineSource: string | undefined, + sourcePath: string | undefined, +): Promise => { + if (inlineSource !== undefined) { + if (sourcePath !== undefined) { + throw new Error('Specify exactly one of function or sourcePath.'); + } + return inlineSource; + } + if (sourcePath === undefined) { + throw new Error('Specify exactly one of function or sourcePath.'); + } + + const resolvedPath = sourcePath.startsWith('file:') + ? fileURLToPath(sourcePath) + : sourcePath; + try { + return await readFile(resolvedPath, 'utf8'); + } catch (error) { + throw new Error(`Unable to read script source from ${sourcePath}.`, { + cause: error, + }); + } +}; + const performEvaluation = async ( evaluatable: Evaluatable, - fnString: string, + source: string, + format: 'function' | 'script', args: Array>, response: Response, - options?: {filePath: string; context: Context}, + options: {filePath?: string; context: Context}, ) => { - using fn = await evaluatable.evaluateHandle(`(${fnString})`); + let result: string | undefined; + if (format === 'function') { + using fn = await evaluatable.evaluateHandle(`(${source})`); + result = await evaluatable.evaluate( + async (fn, ...args) => { + // @ts-expect-error no types for function fn + return JSON.stringify(await fn(...args)); + }, + fn, + ...args, + ); + } else { + const value = await evaluatable.evaluate(source); + result = JSON.stringify(value); + } - const result = await evaluatable.evaluate( - async (fn, ...args) => { - // @ts-expect-error no types for function fn - return JSON.stringify(await fn(...args)); - }, - fn, - ...args, - ); - if (options?.filePath) { + if (options.filePath) { const data = new TextEncoder().encode(result ?? 'undefined'); const {filename} = await options.context.saveFile( data, diff --git a/tests/e2e/chrome-devtools-commands.test.ts b/tests/e2e/chrome-devtools-commands.test.ts index 97d23fb7d..a0ad7db01 100644 --- a/tests/e2e/chrome-devtools-commands.test.ts +++ b/tests/e2e/chrome-devtools-commands.test.ts @@ -6,6 +6,9 @@ import assert from 'node:assert'; import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import {describe, it, afterEach, beforeEach} from 'node:test'; import { @@ -72,6 +75,50 @@ describe('chrome-devtools', () => { ); }); + it('can evaluate inline and local JavaScript', async () => { + const startResult = await runCli(['start'], sessionId); + assert.strictEqual( + startResult.status, + 0, + `start command failed: ${startResult.stderr}`, + ); + + const inlineResult = await runCli( + ['evaluate_script', '() => 6 * 7'], + sessionId, + ); + assert.strictEqual( + inlineResult.status, + 0, + `inline evaluation failed: ${inlineResult.stderr}`, + ); + assert.match(inlineResult.stdout, /\b42\b/); + + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'evaluate-script-cli-'), + ); + const sourcePath = path.join(directory, 'script.js'); + try { + await fs.writeFile( + sourcePath, + 'document.title = "Local script"; document.title', + 'utf8', + ); + const fileResult = await runCli( + ['evaluate_script', '--sourcePath', sourcePath, '--format', 'script'], + sessionId, + ); + assert.strictEqual( + fileResult.status, + 0, + `file evaluation failed: ${fileResult.stderr}`, + ); + assert.match(fileResult.stdout, /Local script/); + } finally { + await fs.rm(directory, {recursive: true, force: true}); + } + }); + it('fails to invoke list_network_requests when categoryNetwork is disabled', async () => { await runCli(['start', '--categoryNetwork=false'], sessionId); diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index d11bf2c49..0b81afe2d 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -5,6 +5,8 @@ */ import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import {describe, it} from 'node:test'; @@ -46,6 +48,124 @@ describe('script', () => { assert.strictEqual(JSON.parse(lineEvaluation), 10); }); }); + it('evaluates an inline classic script', async () => { + await withMcpContext(async (response, context) => { + await evaluateScript().handler( + { + params: { + function: 'document.title = "Script title"; document.title', + format: 'script', + }, + }, + response, + context, + ); + const lineEvaluation = response.responseLines.at(2); + assert.ok(lineEvaluation); + assert.strictEqual(JSON.parse(lineEvaluation), 'Script title'); + }); + }); + it('evaluates a function loaded from a local file', async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'evaluate-script-function-'), + ); + const sourcePath = path.join(directory, 'function.js'); + try { + await fs.writeFile(sourcePath, '() => document.title', 'utf8'); + await withMcpContext(async (response, context) => { + await context + .getSelectedMcpPage() + .pptrPage.setContent('File function'); + await evaluateScript().handler( + {params: {sourcePath}}, + response, + context, + ); + const lineEvaluation = response.responseLines.at(2); + assert.ok(lineEvaluation); + assert.strictEqual(JSON.parse(lineEvaluation), 'File function'); + }); + } finally { + await fs.rm(directory, {recursive: true, force: true}); + } + }); + it('evaluates a classic script loaded from a local file', async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'evaluate-classic-script-'), + ); + const sourcePath = path.join(directory, 'script.js'); + try { + await fs.writeFile( + sourcePath, + 'document.body.dataset.source = "file"; document.body.dataset.source', + 'utf8', + ); + await withMcpContext(async (response, context) => { + await evaluateScript().handler( + {params: {sourcePath, format: 'script'}}, + response, + context, + ); + const lineEvaluation = response.responseLines.at(2); + assert.ok(lineEvaluation); + assert.strictEqual(JSON.parse(lineEvaluation), 'file'); + }); + } finally { + await fs.rm(directory, {recursive: true, force: true}); + } + }); + it('requires exactly one script source', async () => { + await withMcpContext(async (response, context) => { + await assert.rejects( + evaluateScript().handler({params: {}}, response, context), + /Specify exactly one of function or sourcePath/, + ); + await assert.rejects( + evaluateScript().handler( + { + params: { + function: '() => true', + sourcePath: 'script.js', + }, + }, + response, + context, + ), + /Specify exactly one of function or sourcePath/, + ); + }); + }); + it('rejects args for classic scripts', async () => { + await withMcpContext(async (response, context) => { + await assert.rejects( + evaluateScript().handler( + { + params: { + function: 'document.title', + format: 'script', + args: ['1_1'], + }, + }, + response, + context, + ), + /args cannot be used when format is "script"/, + ); + }); + }); + it('reports unreadable source files', async () => { + const sourcePath = path.join( + os.tmpdir(), + 'missing-evaluate-script-source.js', + ); + await fs.rm(sourcePath, {force: true}); + await withMcpContext(async (response, context) => { + await assert.rejects( + evaluateScript().handler({params: {sourcePath}}, response, context), + /Unable to read script source/, + ); + }); + }); it('skips the stable DOM wait when waitForStableDom is false', async () => { await withMcpContext(async (response, context) => { const spy = sinon.spy(WaitForHelper.prototype, 'waitForStableDom'); @@ -334,10 +454,10 @@ describe('script', () => { }); }); it('saves output to file when filePath is provided', async () => { - const {rm, readFile} = await import('node:fs/promises'); - const {tmpdir} = await import('node:os'); - const {join} = await import('node:path'); - const filePath = join(tmpdir(), 'test-evaluate-script-output.json'); + const filePath = path.join( + os.tmpdir(), + 'test-evaluate-script-output.json', + ); try { await withMcpContext(async (response, context) => { await evaluateScript().handler( @@ -356,10 +476,10 @@ describe('script', () => { `Expected "Output saved to" but got: ${response.responseLines[0]}`, ); }); - const content = await readFile(filePath, 'utf-8'); + const content = await fs.readFile(filePath, 'utf-8'); assert.deepStrictEqual(JSON.parse(content), {hello: 'world'}); } finally { - await rm(filePath, {force: true}); + await fs.rm(filePath, {force: true}); } }); it('evaluates inside extension service worker', async () => { @@ -389,9 +509,10 @@ describe('script', () => { await context.triggerExtensionAction(extensionId); response.resetResponseLineForTesting(); - await evaluateScript({ + const extensionEvaluateScript = evaluateScript({ categoryExtensions: true, - } as ParsedArguments).handler( + } as ParsedArguments); + await extensionEvaluateScript.handler( { params: { function: String(() => { @@ -406,6 +527,24 @@ describe('script', () => { const lineEvaluation = response.responseLines.at(2)!; assert.strictEqual(JSON.parse(lineEvaluation), 'has-chrome'); + + response.resetResponseLineForTesting(); + await extensionEvaluateScript.handler( + { + params: { + function: + '"chrome" in globalThis ? "has-chrome-script" : "no-chrome"', + format: 'script', + serviceWorkerId: swId, + }, + }, + response, + context, + ); + const scriptEvaluation = response.responseLines.at(2); + assert.ok(scriptEvaluation); + assert.strictEqual(JSON.parse(scriptEvaluation), 'has-chrome-script'); + await context.uninstallExtension(extensionId); const targets = context.browser.targets(); assertNoServiceWorkerReported(targets, extensionId);