Skip to content

Commit f030726

Browse files
authored
feat(screenshot): adds ability to output screenshot to a specific pat… (#172)
…h. Closes #152. Closes #153
1 parent 6947581 commit f030726

File tree

3 files changed

+124
-1
lines changed

3 files changed

+124
-1
lines changed

docs/tool-reference.md

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

307307
**Parameters:**
308308

309+
- **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.
309310
- **format** (enum: "png", "jpeg") _(optional)_: Type of format to save the screenshot as. Default is "png"
310311
- **fullPage** (boolean) _(optional)_: If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.
311312
- **quality** (number) _(optional)_: Compression quality for JPEG format (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.

src/tools/screenshot.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {writeFile} from 'node:fs/promises';
8+
79
import type {ElementHandle, Page} from 'puppeteer-core';
810
import z from 'zod';
911

@@ -42,6 +44,12 @@ export const screenshot = defineTool({
4244
.describe(
4345
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
4446
),
47+
filePath: z
48+
.string()
49+
.optional()
50+
.describe(
51+
'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
52+
),
4553
},
4654
handler: async (request, response, context) => {
4755
if (request.params.uid && request.params.fullPage) {
@@ -76,7 +84,12 @@ export const screenshot = defineTool({
7684
);
7785
}
7886

79-
if (screenshot.length >= 2_000_000) {
87+
if (request.params.filePath) {
88+
await writeFile(request.params.filePath, screenshot);
89+
response.appendResponseLine(
90+
`Saved screenshot to ${request.params.filePath}.`,
91+
);
92+
} else if (screenshot.length >= 2_000_000) {
8093
const {filename} = await context.saveTemporaryFile(
8194
screenshot,
8295
`image/${request.params.format}`,

tests/tools/screenshot.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66
import assert from 'node:assert';
7+
import {rm, stat, mkdir, chmod, writeFile} from 'node:fs/promises';
8+
import {tmpdir} from 'node:os';
9+
import {join} from 'node:path';
710
import {describe, it} from 'node:test';
811

912
import {screenshot} from '../../src/tools/screenshot.js';
@@ -108,5 +111,111 @@ describe('screenshot', () => {
108111
);
109112
});
110113
});
114+
115+
it('with filePath', async () => {
116+
await withBrowser(async (response, context) => {
117+
const filePath = join(tmpdir(), 'test-screenshot.png');
118+
try {
119+
const fixture = screenshots.basic;
120+
const page = context.getSelectedPage();
121+
await page.setContent(fixture.html);
122+
await screenshot.handler(
123+
{params: {format: 'png', filePath}},
124+
response,
125+
context,
126+
);
127+
128+
assert.equal(response.images.length, 0);
129+
assert.equal(
130+
response.responseLines.at(0),
131+
"Took a screenshot of the current page's viewport.",
132+
);
133+
assert.equal(
134+
response.responseLines.at(1),
135+
`Saved screenshot to ${filePath}.`,
136+
);
137+
138+
const stats = await stat(filePath);
139+
assert.ok(stats.isFile());
140+
assert.ok(stats.size > 0);
141+
} finally {
142+
await rm(filePath, {force: true});
143+
}
144+
});
145+
});
146+
147+
it('with unwritable filePath', async () => {
148+
if (process.platform === 'win32') {
149+
const filePath = join(
150+
tmpdir(),
151+
'readonly-file-for-screenshot-test.png',
152+
);
153+
// Create the file and make it read-only.
154+
await writeFile(filePath, '');
155+
await chmod(filePath, 0o400);
156+
157+
try {
158+
await withBrowser(async (response, context) => {
159+
const fixture = screenshots.basic;
160+
const page = context.getSelectedPage();
161+
await page.setContent(fixture.html);
162+
await assert.rejects(
163+
screenshot.handler(
164+
{params: {format: 'png', filePath}},
165+
response,
166+
context,
167+
),
168+
);
169+
});
170+
} finally {
171+
// Make the file writable again so it can be deleted.
172+
await chmod(filePath, 0o600);
173+
await rm(filePath, {force: true});
174+
}
175+
} else {
176+
const dir = join(tmpdir(), 'readonly-dir-for-screenshot-test');
177+
await mkdir(dir, {recursive: true});
178+
await chmod(dir, 0o500);
179+
const filePath = join(dir, 'test-screenshot.png');
180+
181+
try {
182+
await withBrowser(async (response, context) => {
183+
const fixture = screenshots.basic;
184+
const page = context.getSelectedPage();
185+
await page.setContent(fixture.html);
186+
await assert.rejects(
187+
screenshot.handler(
188+
{params: {format: 'png', filePath}},
189+
response,
190+
context,
191+
),
192+
);
193+
});
194+
} finally {
195+
await chmod(dir, 0o700);
196+
await rm(dir, {recursive: true, force: true});
197+
}
198+
}
199+
});
200+
201+
it('with malformed filePath', async () => {
202+
await withBrowser(async (response, context) => {
203+
// Use a platform-specific invalid character.
204+
// On Windows, characters like '<', '>', ':', '"', '/', '\', '|', '?', '*' are invalid.
205+
// On POSIX, the null byte is invalid.
206+
const invalidChar = process.platform === 'win32' ? '>' : '\0';
207+
const filePath = `malformed${invalidChar}path.png`;
208+
const fixture = screenshots.basic;
209+
const page = context.getSelectedPage();
210+
await page.setContent(fixture.html);
211+
await assert.rejects(
212+
screenshot.handler(
213+
{params: {format: 'png', filePath}},
214+
response,
215+
context,
216+
),
217+
);
218+
});
219+
});
111220
});
112221
});

0 commit comments

Comments
 (0)