Skip to content

Commit 90d368d

Browse files
masamaru0513OrKoN
andauthored
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>
1 parent 71ccdb0 commit 90d368d

5 files changed

Lines changed: 75 additions & 6 deletions

File tree

docs/tool-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ so returned values have to be JSON-serializable.
356356

357357
- **args** (array) _(optional)_: An optional list of arguments to pass to the function.
358358
- **dialogAction** (string) _(optional)_: Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.
359+
- **filePath** (string) _(optional)_: The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.
359360

360361
---
361362

src/bin/chrome-devtools-cli-options.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,13 @@ export const commands: Commands = {
187187
description: 'An optional list of arguments to pass to the function.',
188188
required: false,
189189
},
190+
filePath: {
191+
name: 'filePath',
192+
type: 'string',
193+
description:
194+
'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
195+
required: false,
196+
},
190197
dialogAction: {
191198
name: 'dialogAction',
192199
type: 'string',

src/telemetry/tool_call_metrics.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@
111111
{
112112
"name": "dialog_action_length",
113113
"argType": "number"
114+
},
115+
{
116+
"name": "file_path_length",
117+
"argType": "number"
114118
}
115119
]
116120
},

src/tools/script.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ Example with arguments: \`(el) => {
4646
)
4747
.optional()
4848
.describe(`An optional list of arguments to pass to the function.`),
49+
filePath: zod
50+
.string()
51+
.optional()
52+
.describe(
53+
'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
54+
),
4955
dialogAction: zod
5056
.string()
5157
.optional()
@@ -72,8 +78,11 @@ Example with arguments: \`(el) => {
7278
function: fnString,
7379
pageId,
7480
dialogAction,
81+
filePath,
7582
} = request.params;
7683

84+
context.validatePath(filePath);
85+
7786
if (cliArgs?.categoryExtensions && serviceWorkerId) {
7887
if (uidArgs && uidArgs.length > 0) {
7988
throw new Error(
@@ -89,7 +98,10 @@ Example with arguments: \`(el) => {
8998
.getSelectedMcpPage()
9099
.waitForEventsAfterAction(
91100
async () => {
92-
await performEvaluation(worker, fnString, [], response);
101+
await performEvaluation(worker, fnString, [], response, {
102+
filePath,
103+
context,
104+
});
93105
},
94106
{handleDialog: dialogAction ?? 'accept'},
95107
);
@@ -115,7 +127,10 @@ Example with arguments: \`(el) => {
115127

116128
const result = await mcpPage.waitForEventsAfterAction(
117129
async () => {
118-
await performEvaluation(evaluatable, fnString, args, response);
130+
await performEvaluation(evaluatable, fnString, args, response, {
131+
filePath,
132+
context,
133+
});
119134
},
120135
{handleDialog: dialogAction ?? 'accept'},
121136
);
@@ -132,6 +147,7 @@ const performEvaluation = async (
132147
fnString: string,
133148
args: Array<JSHandle<unknown>>,
134149
response: Response,
150+
options?: {filePath: string; context: Context},
135151
) => {
136152
const fn = await evaluatable.evaluateHandle(`(${fnString})`);
137153
try {
@@ -143,10 +159,22 @@ const performEvaluation = async (
143159
fn,
144160
...args,
145161
);
146-
response.appendResponseLine('Script ran on page and returned:');
147-
response.appendResponseLine('```json');
148-
response.appendResponseLine(`${result}`);
149-
response.appendResponseLine('```');
162+
if (options?.filePath) {
163+
const data = new TextEncoder().encode(result ?? 'undefined');
164+
const {filename} = await options.context.saveFile(
165+
data,
166+
options.filePath,
167+
'.json',
168+
);
169+
response.appendResponseLine(
170+
`Script ran on page. Output saved to ${filename}.`,
171+
);
172+
} else {
173+
response.appendResponseLine('Script ran on page and returned:');
174+
response.appendResponseLine('```json');
175+
response.appendResponseLine(`${result}`);
176+
response.appendResponseLine('```');
177+
}
150178
} finally {
151179
void fn.dispose();
152180
}

tests/tools/script.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,35 @@ describe('script', () => {
279279
assert.strictEqual(JSON.parse(lineEvaluation), 'I am iframe button');
280280
});
281281
});
282+
it('saves output to file when filePath is provided', async () => {
283+
const {rm, readFile} = await import('node:fs/promises');
284+
const {tmpdir} = await import('node:os');
285+
const {join} = await import('node:path');
286+
const filePath = join(tmpdir(), 'test-evaluate-script-output.json');
287+
try {
288+
await withMcpContext(async (response, context) => {
289+
await evaluateScript().handler(
290+
{
291+
params: {
292+
function: String(() => ({hello: 'world'})),
293+
filePath,
294+
},
295+
},
296+
response,
297+
context,
298+
);
299+
assert.strictEqual(response.responseLines.length, 1);
300+
assert.ok(
301+
response.responseLines[0]?.includes('Output saved to'),
302+
`Expected "Output saved to" but got: ${response.responseLines[0]}`,
303+
);
304+
});
305+
const content = await readFile(filePath, 'utf-8');
306+
assert.deepStrictEqual(JSON.parse(content), {hello: 'world'});
307+
} finally {
308+
await rm(filePath, {force: true});
309+
}
310+
});
282311
it('evaluates inside extension service worker', async () => {
283312
await withMcpContext(
284313
async (response, context) => {

0 commit comments

Comments
 (0)