Skip to content

Commit 20ac3b1

Browse files
authored
feat: implement takeViewportScreenshot tool (#26)
* feat: implement takeViewportScreenshot tool
1 parent 7b2ae9b commit 20ac3b1

File tree

5 files changed

+301
-1
lines changed

5 files changed

+301
-1
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,12 @@ Capture a DOM snapshot of the current page with configurable filtering options.
327327

328328
**Note:** By default, only useful tags and attributes are included in snapshots. The response will indicate what was omitted. Use the filtering options only if you need specific content that's not included by default.
329329

330+
### `takeViewportScreenshot`
331+
Capture a PNG screenshot of the current browser viewport.
332+
333+
- **Parameters:**
334+
- `filePath` (string, optional): Path to save the screenshot (defaults to tmp directory with timestamp in file name)
335+
336+
**Note:** Screenshots are saved as PNG files. If no filePath is provided, the screenshot will be saved to the system's temporary directory with a filePath like `viewport-{timestamp}.png`.
337+
330338
</details>

src/browser-context.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,5 @@ export class BrowserContext {
7171
/* empty */
7272
}
7373
return false;
74-
// return this._browser !== null;
7574
}
7675
}

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { clickOnElement } from "./click-on-element.js";
55
import { hoverElement } from "./hover-element.js";
66
import { typeIntoElement } from "./type-into-element.js";
77
import { takePageSnapshot } from "./take-page-snapshot.js";
8+
import { takeViewportScreenshot } from "./take-viewport-screenshot.js";
89
import { listTabs } from "./list-tabs.js";
910
import { switchToTab } from "./switch-to-tab.js";
1011
import { openNewTab } from "./open-new-tab.js";
@@ -19,6 +20,7 @@ export const tools = [
1920
hoverElement,
2021
typeIntoElement,
2122
takePageSnapshot,
23+
takeViewportScreenshot,
2224
listTabs,
2325
switchToTab,
2426
openNewTab,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { z } from "zod";
2+
import { ToolDefinition } from "../types.js";
3+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import { contextProvider } from "../context-provider.js";
5+
import { createBrowserStateResponse, createErrorResponse } from "../responses/index.js";
6+
import path from "path";
7+
import fs from "fs/promises";
8+
import os from "os";
9+
10+
export const takeViewportScreenshotSchema = {
11+
filePath: z.string().optional().describe("Path to save the screenshot (defaults to tmp directory)"),
12+
};
13+
14+
const takeViewportScreenshotCb: ToolCallback<typeof takeViewportScreenshotSchema> = async args => {
15+
try {
16+
const context = contextProvider.getContext();
17+
const browser = await context.browser.get();
18+
19+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
20+
const defaultFilePath = path.join(os.tmpdir(), `viewport-${timestamp}.png`);
21+
const filePath = args.filePath || defaultFilePath;
22+
23+
const screenshotDir = path.dirname(filePath);
24+
await fs.mkdir(screenshotDir, { recursive: true });
25+
26+
await browser.saveScreenshot(filePath);
27+
28+
const fileStats = await fs.stat(filePath);
29+
const fileSizeKB = Math.round(fileStats.size / 1024);
30+
31+
const additionalInfo = `Screenshot saved: ${filePath} (${fileSizeKB} KB)`;
32+
33+
return await createBrowserStateResponse(browser, {
34+
action: "Viewport screenshot captured successfully",
35+
testplaneCode: `await browser.saveScreenshot("${filePath}");`,
36+
additionalInfo,
37+
isSnapshotNeeded: false,
38+
});
39+
} catch (error) {
40+
console.error("Error taking viewport screenshot:", error);
41+
return createErrorResponse("Error taking viewport screenshot", error instanceof Error ? error : undefined);
42+
}
43+
};
44+
45+
export const takeViewportScreenshot: ToolDefinition<typeof takeViewportScreenshotSchema> = {
46+
name: "takeViewportScreenshot",
47+
description:
48+
"Capture a PNG screenshot of the current browser viewport. " +
49+
"Strongly prefer capturing text-based snapshots using takePageSnapshot tool. " +
50+
"Only use to test for visual changes when text-based snapshots are not useful.",
51+
schema: takeViewportScreenshotSchema,
52+
cb: takeViewportScreenshotCb,
53+
};
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
3+
import { startClient } from "../utils";
4+
import { INTEGRATION_TEST_TIMEOUT } from "../constants";
5+
import { PlaygroundServer } from "../test-server";
6+
import fs from "fs/promises";
7+
import path from "path";
8+
import os from "os";
9+
10+
describe(
11+
"tools/takeViewportScreenshot",
12+
() => {
13+
let client: Client;
14+
let playgroundUrl: string;
15+
let testServer: PlaygroundServer;
16+
17+
beforeAll(async () => {
18+
testServer = new PlaygroundServer();
19+
playgroundUrl = await testServer.start();
20+
}, 20000);
21+
22+
afterAll(async () => {
23+
if (testServer) {
24+
await testServer.stop();
25+
}
26+
});
27+
28+
beforeEach(async () => {
29+
client = await startClient();
30+
});
31+
32+
afterEach(async () => {
33+
if (client) {
34+
await client.close();
35+
}
36+
});
37+
38+
describe("takeViewportScreenshot tool availability", () => {
39+
it("should list takeViewportScreenshot tool in available tools", async () => {
40+
const tools = await client.listTools();
41+
42+
const screenshotTool = tools.tools.find(tool => tool.name === "takeViewportScreenshot");
43+
44+
expect(screenshotTool).toBeDefined();
45+
expect(screenshotTool?.description).toContain(
46+
"Capture a PNG screenshot of the current browser viewport",
47+
);
48+
expect(screenshotTool?.inputSchema.properties).toHaveProperty("filePath");
49+
});
50+
});
51+
52+
describe("takeViewportScreenshot tool execution", () => {
53+
it("should capture screenshot with default filePath in tmp directory", async () => {
54+
await client.callTool({
55+
name: "navigate",
56+
arguments: { url: playgroundUrl },
57+
});
58+
59+
const result = await client.callTool({
60+
name: "takeViewportScreenshot",
61+
arguments: {},
62+
});
63+
64+
expect(result.isError).toBe(false);
65+
expect(result.content).toBeDefined();
66+
67+
const content = result.content as Array<{ type: string; text: string }>;
68+
expect(content).toHaveLength(1);
69+
expect(content[0].type).toBe("text");
70+
71+
const responseText = content[0].text;
72+
73+
expect(responseText).toContain("✅ Viewport screenshot captured successfully");
74+
expect(responseText).toContain(os.tmpdir());
75+
expect(responseText).toContain("viewport-");
76+
expect(responseText).toContain(".png");
77+
expect(responseText).toContain("KB)");
78+
79+
const filePathMatch = responseText.match(/Screenshot saved: ([^\s]+)/);
80+
expect(filePathMatch).toBeTruthy();
81+
82+
if (filePathMatch) {
83+
const filePath = filePathMatch[1];
84+
const fileExists = await fs
85+
.access(filePath)
86+
.then(() => true)
87+
.catch(() => false);
88+
expect(fileExists).toBe(true);
89+
90+
if (fileExists) {
91+
const stats = await fs.stat(filePath);
92+
expect(stats.size).toBeGreaterThan(0);
93+
await fs.unlink(filePath).catch(() => {});
94+
}
95+
}
96+
});
97+
98+
it("should capture screenshot with custom filePath", async () => {
99+
const customFilePath = path.join(os.tmpdir(), "test-screenshot.png");
100+
101+
await fs.unlink(customFilePath).catch(() => {});
102+
103+
await client.callTool({
104+
name: "navigate",
105+
arguments: { url: playgroundUrl },
106+
});
107+
108+
const result = await client.callTool({
109+
name: "takeViewportScreenshot",
110+
arguments: { filePath: customFilePath },
111+
});
112+
113+
expect(result.isError).toBe(false);
114+
115+
const content = result.content as Array<{ type: string; text: string }>;
116+
const responseText = content[0].text;
117+
118+
expect(responseText).toContain("✅ Viewport screenshot captured successfully");
119+
expect(responseText).toContain(`Screenshot saved: ${customFilePath}`);
120+
expect(responseText).toContain(`await browser.saveScreenshot("${customFilePath}")`);
121+
122+
const fileExists = await fs
123+
.access(customFilePath)
124+
.then(() => true)
125+
.catch(() => false);
126+
expect(fileExists).toBe(true);
127+
128+
if (fileExists) {
129+
const stats = await fs.stat(customFilePath);
130+
expect(stats.size).toBeGreaterThan(0);
131+
await fs.unlink(customFilePath).catch(() => {});
132+
}
133+
});
134+
135+
it("should create directory if it doesn't exist", async () => {
136+
const testDir = path.join(os.tmpdir(), "test-screenshots-" + Date.now());
137+
const customFilePath = path.join(testDir, "nested", "screenshot.png");
138+
139+
await client.callTool({
140+
name: "navigate",
141+
arguments: { url: playgroundUrl },
142+
});
143+
144+
const result = await client.callTool({
145+
name: "takeViewportScreenshot",
146+
arguments: { filePath: customFilePath },
147+
});
148+
149+
expect(result.isError).toBe(false);
150+
151+
const fileExists = await fs
152+
.access(customFilePath)
153+
.then(() => true)
154+
.catch(() => false);
155+
expect(fileExists).toBe(true);
156+
157+
if (fileExists) {
158+
await fs.rm(testDir, { recursive: true, force: true }).catch(() => {});
159+
}
160+
});
161+
162+
it("should include browser tabs information in response", async () => {
163+
await client.callTool({
164+
name: "navigate",
165+
arguments: { url: playgroundUrl },
166+
});
167+
168+
const result = await client.callTool({
169+
name: "takeViewportScreenshot",
170+
arguments: {},
171+
});
172+
173+
const content = result.content as Array<{ type: string; text: string }>;
174+
const responseText = content[0].text;
175+
176+
expect(responseText).toContain("## Browser Tabs");
177+
expect(responseText).toContain("Element Click Test Playground");
178+
});
179+
180+
it("should not include page snapshot in response", async () => {
181+
await client.callTool({
182+
name: "navigate",
183+
arguments: { url: playgroundUrl },
184+
});
185+
186+
const result = await client.callTool({
187+
name: "takeViewportScreenshot",
188+
arguments: {},
189+
});
190+
191+
const content = result.content as Array<{ type: string; text: string }>;
192+
const responseText = content[0].text;
193+
194+
expect(responseText).not.toContain("## Current Tab Snapshot");
195+
});
196+
});
197+
198+
describe("takeViewportScreenshot error handling", () => {
199+
it("should handle screenshot capture when no page is loaded", async () => {
200+
const result = await client.callTool({
201+
name: "takeViewportScreenshot",
202+
arguments: {},
203+
});
204+
205+
expect(result.isError).toBe(false);
206+
207+
const content = result.content as Array<{ type: string; text: string }>;
208+
expect(content).toHaveLength(1);
209+
210+
const responseText = content[0].text;
211+
expect(responseText).toContain("Screenshot saved:");
212+
});
213+
214+
it("should handle invalid file path gracefully", async () => {
215+
const invalidPath = "/invalid\0path/screenshot.png";
216+
217+
await client.callTool({
218+
name: "navigate",
219+
arguments: { url: playgroundUrl },
220+
});
221+
222+
const result = await client.callTool({
223+
name: "takeViewportScreenshot",
224+
arguments: { filePath: invalidPath },
225+
});
226+
227+
expect(result.isError).toBe(true);
228+
229+
const content = result.content as Array<{ type: string; text: string }>;
230+
const responseText = content[0].text;
231+
232+
expect(responseText).toContain("❌");
233+
expect(responseText).toContain("Error taking viewport screenshot");
234+
});
235+
});
236+
},
237+
INTEGRATION_TEST_TIMEOUT,
238+
);

0 commit comments

Comments
 (0)