Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

### Changed

- 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
- The Appium backend now saves the screenshot to `outputPath` when one is provided (previously the argument was ignored)

### Fixed

- 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)

## [0.2.0]

### Added
Expand Down
71 changes: 71 additions & 0 deletions src/backends/adb-backend.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFile } from 'node:fs/promises';
import { describe, it, expect, vi, beforeEach } from 'vitest';

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

vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));

vi.mock('../utils/exec.js', () => ({
exec: vi.fn(),
execStrict: vi.fn(),
isCommandAvailable: vi.fn().mockResolvedValue(true),
}));

const mockExecStrict = vi.mocked(execModule.execStrict);
const mockReadFile = vi.mocked(readFile);

const SAMPLE_UIAUTOMATOR_XML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
Expand Down Expand Up @@ -170,3 +176,68 @@ describe('AdbBackend.getElementText', () => {
).rejects.toThrow('Element not found');
});
});

describe('AdbBackend.screenshot', () => {
let backend: AdbBackend;

beforeEach(() => {
vi.clearAllMocks();
backend = new AdbBackend('emulator-5554');
});

it('captures with screencap and encodes base64 in-process by default', async () => {
mockExecStrict.mockResolvedValue('');
mockReadFile.mockResolvedValue('QUJDREVG');

const result = await backend.screenshot('/tmp/a.png');

expect(mockExecStrict).toHaveBeenCalledWith('adb', [
'-s',
'emulator-5554',
'pull',
'/sdcard/screenshot.png',
'/tmp/a.png',
]);
expect(mockReadFile).toHaveBeenCalledWith('/tmp/a.png', 'base64');
expect(result).toStrictEqual({
data: 'QUJDREVG',
format: 'png',
path: '/tmp/a.png',
});
});

it('never shells out to the non-portable base64 binary', async () => {
mockExecStrict.mockResolvedValue('');
mockReadFile.mockResolvedValue('Zm9v');

await backend.screenshot('/tmp/a.png');

expect(mockExecStrict).not.toHaveBeenCalledWith(
'base64',
expect.anything(),
);
});

it('skips base64 encoding when encode is false', async () => {
mockExecStrict.mockResolvedValue('');

const result = await backend.screenshot('/tmp/a.png', { encode: false });

expect(mockReadFile).not.toHaveBeenCalled();
expect(result).toStrictEqual({
data: undefined,
format: 'png',
path: '/tmp/a.png',
});
});

it('defaults to a tmp path when none is provided', async () => {
mockExecStrict.mockResolvedValue('');
mockReadFile.mockResolvedValue('YmFy');

const result = await backend.screenshot();

expect(result.path).toMatch(/^\/tmp\/device-mcp-screenshot-\d+\.png$/u);
expect(result.data).toBe('YmFy');
});
});
30 changes: 27 additions & 3 deletions src/backends/adb-backend.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { readFile } from 'node:fs/promises';

import type {
DeviceBackend,
DeviceButton,
DeviceInfo,
SnapshotResult,
ScreenshotResult,
ScreenshotFileResult,
ScreenshotOptions,
LogsResult,
TapResult,
AppStateResult,
Expand Down Expand Up @@ -216,7 +219,25 @@ export class AdbBackend implements DeviceBackend {
return xml;
}

async screenshot(outputPath?: string): Promise<ScreenshotResult> {
screenshot(
outputPath?: string,
options?: { encode?: true },
): Promise<ScreenshotResult>;

screenshot(
outputPath: string | undefined,
options: { encode: false },
): Promise<ScreenshotFileResult>;

screenshot(
outputPath?: string,
options?: ScreenshotOptions,
): Promise<ScreenshotResult | ScreenshotFileResult>;

async screenshot(
outputPath?: string,
options?: ScreenshotOptions,
): Promise<ScreenshotResult | ScreenshotFileResult> {
const localPath =
outputPath ?? `/tmp/device-mcp-screenshot-${Date.now()}.png`;
await this.#adb(['shell', 'screencap', '-p', '/sdcard/screenshot.png']);
Expand All @@ -228,8 +249,11 @@ export class AdbBackend implements DeviceBackend {
localPath,
]);
await this.#adb(['shell', 'rm', '-f', '/sdcard/screenshot.png']);
const data = await execStrict('base64', [localPath]);
return { data: data.trim(), format: 'png', path: localPath };
if (options?.encode === false) {
return { data: undefined, format: 'png', path: localPath };
}
const data = await readFile(localPath, 'base64');
return { data, format: 'png', path: localPath };
}

async openApp(bundleId: string): Promise<void> {
Expand Down
99 changes: 98 additions & 1 deletion src/backends/appium-backend.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import { describe, it, expect } from 'vitest';
import { writeFile } from 'node:fs/promises';
import { resolve as resolvePath } from 'node:path';
import { describe, it, expect, vi, beforeEach } from 'vitest';

import {
AppiumBackend,
parseAppiumAndroidHierarchy,
parseAppiumIosHierarchy,
} from './appium-backend.js';
import type { AttachSessionConfig } from './session-file.js';

const mockGetStatus = vi.fn();
const mockExecute = vi.fn();

vi.mock('./webdriver-client.js', () => ({
WebDriverClient: vi.fn().mockImplementation(() => ({
getStatus: mockGetStatus,
execute: mockExecute,
})),
createSession: vi.fn(),
}));

vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(),
}));

const mockWriteFile = vi.mocked(writeFile);

const SAMPLE_ANDROID_XML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
Expand Down Expand Up @@ -103,3 +124,79 @@ describe('parseAppiumIosHierarchy', () => {
expect(parseAppiumIosHierarchy('')).toStrictEqual([]);
});
});

describe('AppiumBackend.screenshot', () => {
const config: AttachSessionConfig = {
mode: 'attach',
appiumUrl: 'http://localhost:4723',
sessionId: 'session-1',
platform: 'ios',
};

let backend: AppiumBackend;

beforeEach(async () => {
vi.clearAllMocks();
mockGetStatus.mockResolvedValue({});
backend = new AppiumBackend(config);
await backend.ensureConnected();
});

it('returns base64 image data from the WebDriver session by default', async () => {
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');

const result = await backend.screenshot();

expect(mockExecute).toHaveBeenCalledWith('mobile: getScreenshot', []);
expect(mockWriteFile).not.toHaveBeenCalled();
expect(result).toStrictEqual({
data: 'YmFzZTY0LWltYWdl',
format: 'png',
path: undefined,
});
});

it('writes to a temp file and omits base64 data when encode is false', async () => {
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');

const result = await backend.screenshot(undefined, { encode: false });

expect(result.data).toBeUndefined();
expect(result.format).toBe('png');
expect(result.path).toMatch(/^\/tmp\/device-mcp-screenshot-\d+\.png$/u);
expect(mockWriteFile).toHaveBeenCalledWith(
result.path,
Buffer.from('YmFzZTY0LWltYWdl', 'base64'),
);
});

it('writes the decoded image to outputPath when provided', async () => {
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');

const result = await backend.screenshot('/tmp/appium-shot.png');

expect(mockWriteFile).toHaveBeenCalledWith(
resolvePath('/tmp/appium-shot.png'),
Buffer.from('YmFzZTY0LWltYWdl', 'base64'),
);
expect(result.path).toBe(resolvePath('/tmp/appium-shot.png'));
});

it('writes to outputPath and omits base64 data when encode is false', async () => {
mockExecute.mockResolvedValue('YmFzZTY0LWltYWdl');

const result = await backend.screenshot('/tmp/appium-shot.png', {
encode: false,
});

expect(mockWriteFile).toHaveBeenCalledWith(
resolvePath('/tmp/appium-shot.png'),
Buffer.from('YmFzZTY0LWltYWdl', 'base64'),
);
expect(result).toStrictEqual({
data: undefined,
format: 'png',
path: resolvePath('/tmp/appium-shot.png'),
});
});
});
37 changes: 35 additions & 2 deletions src/backends/appium-backend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { writeFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { resolve as resolvePath } from 'node:path';

import type { SessionConfig } from './session-file.js';
Expand All @@ -8,6 +9,8 @@
DeviceInfo,
SnapshotResult,
ScreenshotResult,
ScreenshotFileResult,
ScreenshotOptions,
LogsResult,
TapResult,
AppStateResult,
Expand Down Expand Up @@ -249,10 +252,40 @@
}
}

async screenshot(): Promise<ScreenshotResult> {
screenshot(
outputPath?: string,
options?: { encode?: true },
): Promise<ScreenshotResult>;

screenshot(
outputPath: string | undefined,
options: { encode: false },
): Promise<ScreenshotFileResult>;

screenshot(
outputPath?: string,
options?: ScreenshotOptions,
): Promise<ScreenshotResult | ScreenshotFileResult>;

async screenshot(
outputPath?: string,
options?: ScreenshotOptions,
): Promise<ScreenshotResult | ScreenshotFileResult> {
const client = this.#requireClient();
const data = await client.execute<string>('mobile: getScreenshot', []);
return { data, format: 'png' };
if (options?.encode === false) {
const path = resolvePath(
outputPath ?? `/tmp/device-mcp-screenshot-${Date.now()}.png`,
);
await writeFile(path, Buffer.from(data, 'base64'));

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
return { data: undefined, format: 'png', path };
}
let path: string | undefined;
if (outputPath) {
path = resolvePath(outputPath);
await writeFile(path, Buffer.from(data, 'base64'));

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
}
return { data, format: 'png', path };
}

async openApp(bundleId: string): Promise<void> {
Expand Down
Loading
Loading