Skip to content

Commit 52091c1

Browse files
committed
feat: make screenshot base64 encoding optional
Add an `encode` option to `DeviceBackend.screenshot()` so callers can capture straight to disk and skip base64 via `{ encode: false }`, which returns only the file `path`. The default (`encode: true`) preserves the existing behavior. Adds the `ScreenshotOptions` and `ScreenshotFileResult` public types and makes `ScreenshotResult.data` optional. Also fix `device_screenshot` failing on macOS: the captured PNG is now read in-process with `fs.readFile(path, 'base64')` instead of shelling out to the `base64` CLI, whose BSD variant on macOS rejects the GNU positional-file syntax used previously. The Appium backend now writes the screenshot to `outputPath` when one is provided (the argument was previously ignored). Add screenshot tests across the adb, idb, and appium backends and raise the coverage thresholds accordingly.
1 parent 47f1889 commit 52091c1

12 files changed

Lines changed: 404 additions & 23 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Add Hermes CDP tools (`hermes_cdp`, `hermes_targets`) for inspecting the React Native Hermes JS runtime via Metro
1313

14+
### Changed
15+
16+
- Make screenshot base64 encoding optional — `DeviceBackend.screenshot(outputPath, { encode })` accepts `{ encode: false }` to return only the file `path` and skip base64 encoding. `ScreenshotResult.data` is now optional; the default (`encode: true`) preserves existing behavior
17+
- The Appium backend now saves the screenshot to `outputPath` when one is provided (previously the argument was ignored)
18+
19+
### Fixed
20+
21+
- Fix `device_screenshot` failing on macOS — the captured PNG is now read in-process via `fs.readFile(path, 'base64')` instead of shelling out to the `base64` CLI, which is not portable (BSD `base64` on macOS rejects the GNU positional-file syntax that was used previously)
22+
1423
## [0.2.0]
1524

1625
### Added

src/backends/adb-backend.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFile } from 'node:fs/promises';
12
import { describe, it, expect, vi, beforeEach } from 'vitest';
23

34
import {
@@ -8,13 +9,18 @@ import {
89
import { findElement } from '../utils/element.js';
910
import * as execModule from '../utils/exec.js';
1011

12+
vi.mock('node:fs/promises', () => ({
13+
readFile: vi.fn(),
14+
}));
15+
1116
vi.mock('../utils/exec.js', () => ({
1217
exec: vi.fn(),
1318
execStrict: vi.fn(),
1419
isCommandAvailable: vi.fn().mockResolvedValue(true),
1520
}));
1621

1722
const mockExecStrict = vi.mocked(execModule.execStrict);
23+
const mockReadFile = vi.mocked(readFile);
1824

1925
const SAMPLE_UIAUTOMATOR_XML = `<?xml version="1.0" encoding="UTF-8"?>
2026
<hierarchy rotation="0">
@@ -170,3 +176,68 @@ describe('AdbBackend.getElementText', () => {
170176
).rejects.toThrow('Element not found');
171177
});
172178
});
179+
180+
describe('AdbBackend.screenshot', () => {
181+
let backend: AdbBackend;
182+
183+
beforeEach(() => {
184+
vi.clearAllMocks();
185+
backend = new AdbBackend('emulator-5554');
186+
});
187+
188+
it('captures with screencap and encodes base64 in-process by default', async () => {
189+
mockExecStrict.mockResolvedValue('');
190+
mockReadFile.mockResolvedValue('QUJDREVG');
191+
192+
const result = await backend.screenshot('/tmp/a.png');
193+
194+
expect(mockExecStrict).toHaveBeenCalledWith('adb', [
195+
'-s',
196+
'emulator-5554',
197+
'pull',
198+
'/sdcard/screenshot.png',
199+
'/tmp/a.png',
200+
]);
201+
expect(mockReadFile).toHaveBeenCalledWith('/tmp/a.png', 'base64');
202+
expect(result).toStrictEqual({
203+
data: 'QUJDREVG',
204+
format: 'png',
205+
path: '/tmp/a.png',
206+
});
207+
});
208+
209+
it('never shells out to the non-portable base64 binary', async () => {
210+
mockExecStrict.mockResolvedValue('');
211+
mockReadFile.mockResolvedValue('Zm9v');
212+
213+
await backend.screenshot('/tmp/a.png');
214+
215+
expect(mockExecStrict).not.toHaveBeenCalledWith(
216+
'base64',
217+
expect.anything(),
218+
);
219+
});
220+
221+
it('skips base64 encoding when encode is false', async () => {
222+
mockExecStrict.mockResolvedValue('');
223+
224+
const result = await backend.screenshot('/tmp/a.png', { encode: false });
225+
226+
expect(mockReadFile).not.toHaveBeenCalled();
227+
expect(result).toStrictEqual({
228+
data: undefined,
229+
format: 'png',
230+
path: '/tmp/a.png',
231+
});
232+
});
233+
234+
it('defaults to a tmp path when none is provided', async () => {
235+
mockExecStrict.mockResolvedValue('');
236+
mockReadFile.mockResolvedValue('YmFy');
237+
238+
const result = await backend.screenshot();
239+
240+
expect(result.path).toMatch(/^\/tmp\/device-mcp-screenshot-\d+\.png$/u);
241+
expect(result.data).toBe('YmFy');
242+
});
243+
});

src/backends/adb-backend.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { spawn } from 'node:child_process';
22
import type { ChildProcess } from 'node:child_process';
3+
import { readFile } from 'node:fs/promises';
34

45
import type {
56
DeviceBackend,
67
DeviceButton,
78
DeviceInfo,
89
SnapshotResult,
910
ScreenshotResult,
11+
ScreenshotFileResult,
12+
ScreenshotOptions,
1013
LogsResult,
1114
TapResult,
1215
AppStateResult,
@@ -216,7 +219,25 @@ export class AdbBackend implements DeviceBackend {
216219
return xml;
217220
}
218221

219-
async screenshot(outputPath?: string): Promise<ScreenshotResult> {
222+
screenshot(
223+
outputPath?: string,
224+
options?: { encode?: true },
225+
): Promise<ScreenshotResult>;
226+
227+
screenshot(
228+
outputPath: string | undefined,
229+
options: { encode: false },
230+
): Promise<ScreenshotFileResult>;
231+
232+
screenshot(
233+
outputPath?: string,
234+
options?: ScreenshotOptions,
235+
): Promise<ScreenshotResult | ScreenshotFileResult>;
236+
237+
async screenshot(
238+
outputPath?: string,
239+
options?: ScreenshotOptions,
240+
): Promise<ScreenshotResult | ScreenshotFileResult> {
220241
const localPath =
221242
outputPath ?? `/tmp/device-mcp-screenshot-${Date.now()}.png`;
222243
await this.#adb(['shell', 'screencap', '-p', '/sdcard/screenshot.png']);
@@ -228,8 +249,11 @@ export class AdbBackend implements DeviceBackend {
228249
localPath,
229250
]);
230251
await this.#adb(['shell', 'rm', '-f', '/sdcard/screenshot.png']);
231-
const data = await execStrict('base64', [localPath]);
232-
return { data: data.trim(), format: 'png', path: localPath };
252+
if (options?.encode === false) {
253+
return { data: undefined, format: 'png', path: localPath };
254+
}
255+
const data = await readFile(localPath, 'base64');
256+
return { data, format: 'png', path: localPath };
233257
}
234258

235259
async openApp(bundleId: string): Promise<void> {

src/backends/appium-backend.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { writeFile } from 'node:fs/promises';
2+
import { resolve as resolvePath } from 'node:path';
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
24

35
import {
6+
AppiumBackend,
47
parseAppiumAndroidHierarchy,
58
parseAppiumIosHierarchy,
69
} from './appium-backend.js';
10+
import type { AttachSessionConfig } from './session-file.js';
11+
12+
const mockGetStatus = vi.fn();
13+
const mockExecute = vi.fn();
14+
15+
vi.mock('./webdriver-client.js', () => ({
16+
WebDriverClient: vi.fn().mockImplementation(() => ({
17+
getStatus: mockGetStatus,
18+
execute: mockExecute,
19+
})),
20+
createSession: vi.fn(),
21+
}));
22+
23+
vi.mock('node:fs/promises', () => ({
24+
writeFile: vi.fn(),
25+
}));
26+
27+
const mockWriteFile = vi.mocked(writeFile);
728

829
const SAMPLE_ANDROID_XML = `<?xml version="1.0" encoding="UTF-8"?>
930
<hierarchy rotation="0">
@@ -103,3 +124,79 @@ describe('parseAppiumIosHierarchy', () => {
103124
expect(parseAppiumIosHierarchy('')).toStrictEqual([]);
104125
});
105126
});
127+
128+
describe('AppiumBackend.screenshot', () => {
129+
const config: AttachSessionConfig = {
130+
mode: 'attach',
131+
appiumUrl: 'http://localhost:4723',
132+
sessionId: 'session-1',
133+
platform: 'ios',
134+
};
135+
136+
let backend: AppiumBackend;
137+
138+
beforeEach(async () => {
139+
vi.clearAllMocks();
140+
mockGetStatus.mockResolvedValue({});
141+
backend = new AppiumBackend(config);
142+
await backend.ensureConnected();
143+
});
144+
145+
it('returns base64 image data from the WebDriver session by default', async () => {
146+
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');
147+
148+
const result = await backend.screenshot();
149+
150+
expect(mockExecute).toHaveBeenCalledWith('mobile: getScreenshot', []);
151+
expect(mockWriteFile).not.toHaveBeenCalled();
152+
expect(result).toStrictEqual({
153+
data: 'YmFzZTY0LWltYWdl',
154+
format: 'png',
155+
path: undefined,
156+
});
157+
});
158+
159+
it('writes to a temp file and omits base64 data when encode is false', async () => {
160+
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');
161+
162+
const result = await backend.screenshot(undefined, { encode: false });
163+
164+
expect(result.data).toBeUndefined();
165+
expect(result.format).toBe('png');
166+
expect(result.path).toMatch(/^\/tmp\/device-mcp-screenshot-\d+\.png$/u);
167+
expect(mockWriteFile).toHaveBeenCalledWith(
168+
result.path,
169+
Buffer.from('YmFzZTY0LWltYWdl', 'base64'),
170+
);
171+
});
172+
173+
it('writes the decoded image to outputPath when provided', async () => {
174+
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');
175+
176+
const result = await backend.screenshot('/tmp/appium-shot.png');
177+
178+
expect(mockWriteFile).toHaveBeenCalledWith(
179+
resolvePath('/tmp/appium-shot.png'),
180+
Buffer.from('YmFzZTY0LWltYWdl', 'base64'),
181+
);
182+
expect(result.path).toBe(resolvePath('/tmp/appium-shot.png'));
183+
});
184+
185+
it('writes to outputPath and omits base64 data when encode is false', async () => {
186+
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');
187+
188+
const result = await backend.screenshot('/tmp/appium-shot.png', {
189+
encode: false,
190+
});
191+
192+
expect(mockWriteFile).toHaveBeenCalledWith(
193+
resolvePath('/tmp/appium-shot.png'),
194+
Buffer.from('YmFzZTY0LWltYWdl', 'base64'),
195+
);
196+
expect(result).toStrictEqual({
197+
data: undefined,
198+
format: 'png',
199+
path: resolvePath('/tmp/appium-shot.png'),
200+
});
201+
});
202+
});

src/backends/appium-backend.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { writeFileSync } from 'node:fs';
2+
import { writeFile } from 'node:fs/promises';
23
import { resolve as resolvePath } from 'node:path';
34

45
import type { SessionConfig } from './session-file.js';
@@ -8,6 +9,8 @@ import type {
89
DeviceInfo,
910
SnapshotResult,
1011
ScreenshotResult,
12+
ScreenshotFileResult,
13+
ScreenshotOptions,
1114
LogsResult,
1215
TapResult,
1316
AppStateResult,
@@ -249,10 +252,40 @@ export class AppiumBackend implements DeviceBackend {
249252
}
250253
}
251254

252-
async screenshot(): Promise<ScreenshotResult> {
255+
screenshot(
256+
outputPath?: string,
257+
options?: { encode?: true },
258+
): Promise<ScreenshotResult>;
259+
260+
screenshot(
261+
outputPath: string | undefined,
262+
options: { encode: false },
263+
): Promise<ScreenshotFileResult>;
264+
265+
screenshot(
266+
outputPath?: string,
267+
options?: ScreenshotOptions,
268+
): Promise<ScreenshotResult | ScreenshotFileResult>;
269+
270+
async screenshot(
271+
outputPath?: string,
272+
options?: ScreenshotOptions,
273+
): Promise<ScreenshotResult | ScreenshotFileResult> {
253274
const client = this.#requireClient();
254275
const data = await client.execute<string>('mobile: getScreenshot', []);
255-
return { data, format: 'png' };
276+
if (options?.encode === false) {
277+
const path = resolvePath(
278+
outputPath ?? `/tmp/device-mcp-screenshot-${Date.now()}.png`,
279+
);
280+
await writeFile(path, Buffer.from(data, 'base64'));
281+
return { data: undefined, format: 'png', path };
282+
}
283+
let path: string | undefined;
284+
if (outputPath) {
285+
path = resolvePath(outputPath);
286+
await writeFile(path, Buffer.from(data, 'base64'));
287+
}
288+
return { data, format: 'png', path };
256289
}
257290

258291
async openApp(bundleId: string): Promise<void> {

0 commit comments

Comments
 (0)