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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
{
|
||||
"name": "dialog_action_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "file_path_length",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+34
-6
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user