diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 1b39e869a..517cb75e9 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -306,6 +306,7 @@ so returned values have to JSON-serializable. **Parameters:** +- **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response. - **format** (enum: "png", "jpeg") _(optional)_: Type of format to save the screenshot as. Default is "png" - **fullPage** (boolean) _(optional)_: If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid. - **quality** (number) _(optional)_: Compression quality for JPEG format (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format. diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index ae1f98bf4..f92f8005e 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {writeFile} from 'node:fs/promises'; + import type {ElementHandle, Page} from 'puppeteer-core'; import z from 'zod'; @@ -42,6 +44,12 @@ export const screenshot = defineTool({ .describe( 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.', ), + filePath: z + .string() + .optional() + .describe( + 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.', + ), }, handler: async (request, response, context) => { if (request.params.uid && request.params.fullPage) { @@ -76,7 +84,12 @@ export const screenshot = defineTool({ ); } - if (screenshot.length >= 2_000_000) { + if (request.params.filePath) { + await writeFile(request.params.filePath, screenshot); + response.appendResponseLine( + `Saved screenshot to ${request.params.filePath}.`, + ); + } else if (screenshot.length >= 2_000_000) { const {filename} = await context.saveTemporaryFile( screenshot, `image/${request.params.format}`, diff --git a/tests/tools/screenshot.test.ts b/tests/tools/screenshot.test.ts index e6cdda171..6902737f2 100644 --- a/tests/tools/screenshot.test.ts +++ b/tests/tools/screenshot.test.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; +import {rm, stat, mkdir, chmod, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {describe, it} from 'node:test'; import {screenshot} from '../../src/tools/screenshot.js'; @@ -108,5 +111,111 @@ describe('screenshot', () => { ); }); }); + + it('with filePath', async () => { + await withBrowser(async (response, context) => { + const filePath = join(tmpdir(), 'test-screenshot.png'); + try { + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ); + + assert.equal(response.images.length, 0); + assert.equal( + response.responseLines.at(0), + "Took a screenshot of the current page's viewport.", + ); + assert.equal( + response.responseLines.at(1), + `Saved screenshot to ${filePath}.`, + ); + + const stats = await stat(filePath); + assert.ok(stats.isFile()); + assert.ok(stats.size > 0); + } finally { + await rm(filePath, {force: true}); + } + }); + }); + + it('with unwritable filePath', async () => { + if (process.platform === 'win32') { + const filePath = join( + tmpdir(), + 'readonly-file-for-screenshot-test.png', + ); + // Create the file and make it read-only. + await writeFile(filePath, ''); + await chmod(filePath, 0o400); + + try { + await withBrowser(async (response, context) => { + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await assert.rejects( + screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ), + ); + }); + } finally { + // Make the file writable again so it can be deleted. + await chmod(filePath, 0o600); + await rm(filePath, {force: true}); + } + } else { + const dir = join(tmpdir(), 'readonly-dir-for-screenshot-test'); + await mkdir(dir, {recursive: true}); + await chmod(dir, 0o500); + const filePath = join(dir, 'test-screenshot.png'); + + try { + await withBrowser(async (response, context) => { + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await assert.rejects( + screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ), + ); + }); + } finally { + await chmod(dir, 0o700); + await rm(dir, {recursive: true, force: true}); + } + } + }); + + it('with malformed filePath', async () => { + await withBrowser(async (response, context) => { + // Use a platform-specific invalid character. + // On Windows, characters like '<', '>', ':', '"', '/', '\', '|', '?', '*' are invalid. + // On POSIX, the null byte is invalid. + const invalidChar = process.platform === 'win32' ? '>' : '\0'; + const filePath = `malformed${invalidChar}path.png`; + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await assert.rejects( + screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ), + ); + }); + }); }); });