feat: support filePath in evaluate_script (#2054)

## Summary

Adds an optional `filePath` parameter to `evaluate_script` that saves
the script output to a file instead of returning it inline.

Refs #153

## Motivation

Issue #153 requested `filePath` support for `take_snapshot` and
`evaluate_script`. `take_snapshot` was addressed in #463. PR #248
previously attempted this but was closed due to conflicts. This PR
implements the same feature on the current codebase, completing the
remaining piece.

## Changes

- Add optional `filePath` parameter to the `evaluate_script` schema
- Add `context.validatePath(filePath)` call for path validation
- Pass `{filePath, context}` options to `performEvaluation()`
- In `performEvaluation()`: when `filePath` is provided, save output via
`context.saveFile()` with `.json` extension; otherwise return inline as
before
- Update `docs/tool-reference.md` via `npm run docs:generate`
- Add unit test for file output

## Key design decisions

- **Same pattern as existing tools**: Follows the `context.saveFile()`
pattern established by `take_snapshot` (#463), `take_screenshot`,
`get_network_request` (#795), and performance tools (#686).
- **Minimal change surface**: Only `performEvaluation()` gains an
optional `options` parameter. No new interfaces or abstractions.
- **Backwards compatible**: `filePath` is optional. When omitted,
behavior is identical to before.

## Testing

**Unit test added** (`tests/tools/script.test.ts`):
- Call `evaluate_script` with `filePath` set to a temp file
- Assert response contains "Output saved to"
- Assert file content matches the JSON-serialized return value
- Clean up temp file in `finally` block

**Manual testing performed**:
- `() => document.title` with `filePath: /tmp/test.json` → file contains
`"Example Domain"`
- `() => document.title` without `filePath` → inline ```json block
returned (no regression)
- `() => Array.from({length: 100}, ...)` with `filePath` → 100-item
array saved correctly
- `filePath` pointing to non-existent directory → directory
auto-created, file saved
- Relative path (`test.json`) → resolved to CWD, absolute path shown in
response
- Function that throws → error returned, no partial file created
- Existing file as `filePath` → file overwritten completely

---------

Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
This commit is contained in:
@masamaru0513
2026-05-14 17:33:27 +09:00
committed by GitHub
parent 71ccdb0dbf
commit 90d368d343
5 changed files with 75 additions and 6 deletions
+1
View File
@@ -356,6 +356,7 @@ so returned values have to be JSON-serializable.
- **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.
---
+7
View File
@@ -187,6 +187,13 @@ export const commands: Commands = {
description: 'An optional list of arguments to pass to the function.',
required: false,
},
filePath: {
name: 'filePath',
type: 'string',
description:
'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
required: false,
},
dialogAction: {
name: 'dialogAction',
type: 'string',
+4
View File
@@ -111,6 +111,10 @@
{
"name": "dialog_action_length",
"argType": "number"
},
{
"name": "file_path_length",
"argType": "number"
}
]
},
+34 -6
View File
@@ -46,6 +46,12 @@ Example with arguments: \`(el) => {
)
.optional()
.describe(`An optional list of arguments to pass to the function.`),
filePath: zod
.string()
.optional()
.describe(
'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
),
dialogAction: zod
.string()
.optional()
@@ -72,8 +78,11 @@ Example with arguments: \`(el) => {
function: fnString,
pageId,
dialogAction,
filePath,
} = request.params;
context.validatePath(filePath);
if (cliArgs?.categoryExtensions && serviceWorkerId) {
if (uidArgs && uidArgs.length > 0) {
throw new Error(
@@ -89,7 +98,10 @@ Example with arguments: \`(el) => {
.getSelectedMcpPage()
.waitForEventsAfterAction(
async () => {
await performEvaluation(worker, fnString, [], response);
await performEvaluation(worker, fnString, [], response, {
filePath,
context,
});
},
{handleDialog: dialogAction ?? 'accept'},
);
@@ -115,7 +127,10 @@ Example with arguments: \`(el) => {
const result = await mcpPage.waitForEventsAfterAction(
async () => {
await performEvaluation(evaluatable, fnString, args, response);
await performEvaluation(evaluatable, fnString, args, response, {
filePath,
context,
});
},
{handleDialog: dialogAction ?? 'accept'},
);
@@ -132,6 +147,7 @@ const performEvaluation = async (
fnString: string,
args: Array<JSHandle<unknown>>,
response: Response,
options?: {filePath: string; context: Context},
) => {
const fn = await evaluatable.evaluateHandle(`(${fnString})`);
try {
@@ -143,10 +159,22 @@ const performEvaluation = async (
fn,
...args,
);
response.appendResponseLine('Script ran on page and returned:');
response.appendResponseLine('```json');
response.appendResponseLine(`${result}`);
response.appendResponseLine('```');
if (options?.filePath) {
const data = new TextEncoder().encode(result ?? 'undefined');
const {filename} = await options.context.saveFile(
data,
options.filePath,
'.json',
);
response.appendResponseLine(
`Script ran on page. Output saved to ${filename}.`,
);
} else {
response.appendResponseLine('Script ran on page and returned:');
response.appendResponseLine('```json');
response.appendResponseLine(`${result}`);
response.appendResponseLine('```');
}
} finally {
void fn.dispose();
}
+29
View File
@@ -279,6 +279,35 @@ describe('script', () => {
assert.strictEqual(JSON.parse(lineEvaluation), 'I am iframe button');
});
});
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');
try {
await withMcpContext(async (response, context) => {
await evaluateScript().handler(
{
params: {
function: String(() => ({hello: 'world'})),
filePath,
},
},
response,
context,
);
assert.strictEqual(response.responseLines.length, 1);
assert.ok(
response.responseLines[0]?.includes('Output saved to'),
`Expected "Output saved to" but got: ${response.responseLines[0]}`,
);
});
const content = await readFile(filePath, 'utf-8');
assert.deepStrictEqual(JSON.parse(content), {hello: 'world'});
} finally {
await rm(filePath, {force: true});
}
});
it('evaluates inside extension service worker', async () => {
await withMcpContext(
async (response, context) => {