Skip to content

Commit efaa39f

Browse files
authored
Merge pull request #38 from Lykhoyda/enhance/b120-screenshot-resize
feat(b120): downscale device_screenshot via macOS sips — closes #36
2 parents dc0aa04 + 6ba7621 commit efaa39f

10 files changed

Lines changed: 699 additions & 20 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.25.1",
12+
"version": "0.26.0",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.25.1",
3+
"version": "0.26.0",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

scripts/cdp-bridge/dist/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,12 @@ trackedTool('collect_logs', 'Collect logs from multiple sources in parallel: JS
285285
}, createCollectLogsHandler(getClient));
286286
// --- agent-device tools (native device interaction) ---
287287
trackedTool('device_list', 'List all available iOS simulators and Android emulators. Returns device name, UDID, platform, and status. Use before device_snapshot action=open to confirm the target device.', {}, createDeviceListHandler());
288-
trackedTool('device_screenshot', 'Capture a screenshot of the active device screen. Returns image data or file path. Prefer JPEG for faster capture. When both iOS sim and Android emulator are booted, defaults to the platform of the currently connected CDP target; override with `platform` if needed.', {
288+
trackedTool('device_screenshot', 'Capture a screenshot of the active device screen. Returns the file path. Prefer JPEG for faster capture. When both iOS sim and Android emulator are booted, defaults to the platform of the currently connected CDP target. Output is auto-downscaled to maxWidth (default 800px) via macOS sips to keep LLM context costs predictable; pass maxWidth=0 to disable when full-resolution capture is needed (visual diffing). meta.resize describes what happened.', {
289289
path: z.string().optional().describe('Output file path (default: auto-generated in /tmp). Use .jpg extension for JPEG.'),
290290
format: z.enum(['jpeg', 'png']).optional().describe('Image format (default: auto-detect from path extension, or jpeg)'),
291291
platform: z.enum(['ios', 'android']).optional().describe('Target device platform. Defaults to the currently-connected CDP target platform.'),
292+
maxWidth: z.number().int().min(0).optional().describe('Downscale image so width does not exceed this many pixels. 0 disables resize. Default 800 (saves ~46% on iPhone 15/17 Pro screenshots without losing label readability).'),
293+
quality: z.number().int().min(1).max(100).optional().describe('JPEG compression quality (1-100). Only applied to .jpg/.jpeg files. Default 85.'),
292294
}, createDeviceScreenshotHandler(getClient));
293295
trackedTool('device_snapshot', 'Manage device sessions and capture UI snapshots. action=open starts a session (required before other device_ tools). action=snapshot returns the accessibility tree with @ref identifiers for device_press/device_fill. action=close ends the session. Use attachOnly=true on action=open to skip launching the app when it is already running (avoids relaunch-induced bundle races — B112).', {
294296
action: z.enum(['open', 'close', 'snapshot']).default('snapshot').describe('open: start session for an app. snapshot: capture UI tree with element refs. close: end session.'),
Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import { runAgentDevice } from '../agent-device-wrapper.js';
2+
import { resizeWithSips } from './device-screenshot-resize.js';
23
export function createDeviceListHandler() {
34
return async () => runAgentDevice(['devices'], { skipSession: true });
45
}
6+
/**
7+
* Pure derivation of the output path for a screenshot call. Extracted so the
8+
* handler can know the path independently from `buildScreenshotArgs` (used to
9+
* pass it to the post-resize step) and to keep `buildScreenshotArgs` tests stable.
10+
*/
11+
export function deriveScreenshotPath(args, now = Date.now) {
12+
if (args.path)
13+
return args.path;
14+
const ext = args.format === 'jpeg' ? 'jpg' : args.format === 'png' ? 'png' : 'jpg';
15+
return `/tmp/rn-screenshot-${now()}.${ext}`;
16+
}
517
/**
618
* B113 fix (D636): agent-device >= 0.8.0 exposes only `[path]` and `--out <path>`
719
* — no `--format`. Emitting --format caused 100% failure ("Unknown flag: --format").
@@ -11,22 +23,88 @@ export function createDeviceListHandler() {
1123
* Exported for unit tests — pure function, no I/O.
1224
*/
1325
export function buildScreenshotArgs(args, now = Date.now) {
14-
let outputPath = args.path;
15-
if (!outputPath) {
16-
const ext = args.format === 'jpeg' ? 'jpg' : args.format === 'png' ? 'png' : 'jpg';
17-
outputPath = `/tmp/rn-screenshot-${now()}.${ext}`;
26+
return ['screenshot', '--out', deriveScreenshotPath(args, now)];
27+
}
28+
/**
29+
* B120 / GH #36: extract the path agent-device actually wrote to. Daemon and
30+
* Swift-runner paths echo it via `data.path`; fast-runner uses its own tmp
31+
* file (`/tmp/rn-fast-screenshot-*.png`) regardless of `--out`, also exposed
32+
* via `data.path`. Falls back to the requested path when the response shape
33+
* is unexpected so resize still has a target to attempt.
34+
*/
35+
export function resolveScreenshotPath(result, fallback) {
36+
try {
37+
const envelope = JSON.parse(result.content[0].text);
38+
const candidate = envelope?.data?.path;
39+
if (typeof candidate === 'string' && candidate.startsWith('/')) {
40+
return candidate;
41+
}
42+
}
43+
catch { /* malformed envelope — use fallback */ }
44+
return fallback;
45+
}
46+
export function wrapResultWithResize(result, resize) {
47+
if (result.isError)
48+
return result;
49+
try {
50+
const envelope = JSON.parse(result.content[0].text);
51+
const resizeMeta = { resized: resize.resized };
52+
if (resize.resized) {
53+
if (resize.originalDims)
54+
resizeMeta.fromDims = resize.originalDims;
55+
if (resize.newDims)
56+
resizeMeta.toDims = resize.newDims;
57+
if (resize.originalBytes !== undefined)
58+
resizeMeta.fromBytes = resize.originalBytes;
59+
if (resize.newBytes !== undefined)
60+
resizeMeta.toBytes = resize.newBytes;
61+
if (resize.originalBytes && resize.newBytes) {
62+
resizeMeta.savedPercent = Math.round((1 - resize.newBytes / resize.originalBytes) * 100);
63+
}
64+
}
65+
else if (resize.reason) {
66+
resizeMeta.reason = resize.reason;
67+
}
68+
envelope.meta = { ...envelope.meta, resize: resizeMeta };
69+
if (envelope.data && resize.resized) {
70+
envelope.data.path = resize.path;
71+
}
72+
return { content: [{ type: 'text', text: JSON.stringify(envelope) }] };
73+
}
74+
catch {
75+
return result;
1876
}
19-
return ['screenshot', '--out', outputPath];
2077
}
2178
/**
22-
* B117/D638: device_screenshot now accepts an optional `platform` and, when not
79+
* B117/D638: device_screenshot accepts an optional `platform` and, when not
2380
* provided, falls back to the current CDP target's platform. Prevents
2481
* wrong-device screenshots when both iOS sim and Android emulator are booted.
82+
*
83+
* B120 / GH #36: post-process via macOS `sips` to downscale native-resolution
84+
* images that otherwise blow LLM context budgets. Defaults to maxWidth=1200,
85+
* quality=85 (JPEG only). Set maxWidth=0 to disable. Gracefully degrades on
86+
* non-macOS / missing sips — original screenshot is still returned with a
87+
* `meta.resize.reason` explaining why.
88+
*
2589
* getClient is optional so existing callers/tests still compile.
2690
*/
2791
export function createDeviceScreenshotHandler(getClient) {
2892
return async (args) => {
2993
const platform = args.platform ?? getClient?.()?.connectedTarget?.platform ?? null;
30-
return runAgentDevice(buildScreenshotArgs(args), { platform });
94+
const requestedPath = deriveScreenshotPath(args);
95+
// Pin the path explicitly so the post-resize step targets the same file
96+
// regardless of which dispatch tier (fast-runner / daemon / CLI) responded.
97+
const argsWithPath = { ...args, path: requestedPath };
98+
const result = await runAgentDevice(buildScreenshotArgs(argsWithPath), { platform });
99+
if (result.isError)
100+
return result;
101+
const actualPath = resolveScreenshotPath(result, requestedPath);
102+
const resizeOpts = {};
103+
if (args.maxWidth !== undefined)
104+
resizeOpts.maxWidth = args.maxWidth;
105+
if (args.quality !== undefined)
106+
resizeOpts.quality = args.quality;
107+
const resize = await resizeWithSips(actualPath, resizeOpts);
108+
return wrapResultWithResize(result, resize);
31109
};
32110
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { execFile as execFileCb } from 'node:child_process';
2+
import { promisify } from 'node:util';
3+
import { statSync } from 'node:fs';
4+
const execFile = promisify(execFileCb);
5+
/**
6+
* B120 / GH #36: defaults validated against a live iPhone 17 Pro screenshot
7+
* (native 1206×2622, ~193 KB JPEG). Empirical measurements:
8+
* maxWidth=1200 → 181 KB (−7%, near no-op on modern iPhones)
9+
* maxWidth=1000 → 144 KB (−25%)
10+
* maxWidth=800 → 105 KB (−46%) ← matches the issue's suggested default
11+
* maxWidth=600 → 68 KB (−65%)
12+
* Picked 800 to match the issue's suggestion and produce meaningful savings
13+
* even on devices whose native width is already close to 1200. Still leaves
14+
* text labels at ~35-40 px tall — comfortably readable for any agent task.
15+
* Set maxWidth=0 to disable when full-resolution capture is required (visual
16+
* diffing, pixel comparisons).
17+
*/
18+
export const DEFAULT_MAX_WIDTH = 800;
19+
export const DEFAULT_QUALITY = 85;
20+
let sipsAvailable = null;
21+
const defaultFileSize = (path) => {
22+
try {
23+
return statSync(path).size;
24+
}
25+
catch {
26+
return undefined;
27+
}
28+
};
29+
async function checkSipsAvailable(deps) {
30+
if (sipsAvailable !== null)
31+
return sipsAvailable;
32+
const runner = deps.exec ?? execFile;
33+
try {
34+
await runner('sips', ['--version'], { timeout: 1500 });
35+
sipsAvailable = true;
36+
}
37+
catch {
38+
sipsAvailable = false;
39+
}
40+
return sipsAvailable;
41+
}
42+
/** Test-only: reset the cached `sips --version` probe. */
43+
export function resetSipsProbeForTesting() {
44+
sipsAvailable = null;
45+
}
46+
export function parseSipsDimensions(stdout) {
47+
// sips -g pixelWidth -g pixelHeight <path> output:
48+
// /path/to/file.jpg
49+
// pixelWidth: 1179
50+
// pixelHeight: 2556
51+
const wMatch = stdout.match(/pixelWidth:\s*(\d+)/);
52+
const hMatch = stdout.match(/pixelHeight:\s*(\d+)/);
53+
if (!wMatch || !hMatch)
54+
return null;
55+
return { width: parseInt(wMatch[1], 10), height: parseInt(hMatch[1], 10) };
56+
}
57+
async function getDimensions(path, deps) {
58+
const runner = deps.exec ?? execFile;
59+
try {
60+
const { stdout } = await runner('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', path], { timeout: 5000, encoding: 'utf8' });
61+
return parseSipsDimensions(stdout);
62+
}
63+
catch {
64+
return null;
65+
}
66+
}
67+
export function buildSipsResizeArgs(path, maxWidth, quality) {
68+
const args = ['--resampleWidth', String(maxWidth)];
69+
// JPEG quality only applies to .jpg/.jpeg files. sips accepts the flag for
70+
// PNG paths but it's a no-op there — still safe, just noise — so we gate it
71+
// on extension to keep the args minimal.
72+
if (quality !== undefined && /\.jpe?g$/i.test(path)) {
73+
args.push('-s', 'formatOptions', String(quality));
74+
}
75+
args.push(path);
76+
return args;
77+
}
78+
/**
79+
* Downscale an image at `path` in place via macOS `sips`. Returns details about
80+
* what happened (or why no resize was performed). Never throws — degraded
81+
* environments (Linux, missing sips, unreadable file) return `resized: false`
82+
* with a reason, so the screenshot is still usable.
83+
*/
84+
export async function resizeWithSips(path, opts = {}, deps = {}) {
85+
const maxWidth = opts.maxWidth ?? DEFAULT_MAX_WIDTH;
86+
if (maxWidth <= 0) {
87+
return { resized: false, path, reason: 'maxWidth-zero' };
88+
}
89+
if (!(await checkSipsAvailable(deps))) {
90+
return { resized: false, path, reason: 'sips-unavailable' };
91+
}
92+
const originalDims = await getDimensions(path, deps);
93+
if (!originalDims) {
94+
return { resized: false, path, reason: 'no-dimensions' };
95+
}
96+
if (originalDims.width <= maxWidth) {
97+
return { resized: false, path, reason: 'already-smaller', originalDims };
98+
}
99+
const fileSize = deps.fileSize ?? defaultFileSize;
100+
const originalBytes = fileSize(path);
101+
const runner = deps.exec ?? execFile;
102+
const quality = opts.quality ?? DEFAULT_QUALITY;
103+
try {
104+
await runner('sips', buildSipsResizeArgs(path, maxWidth, quality), { timeout: 10_000 });
105+
}
106+
catch {
107+
return { resized: false, path, reason: 'sips-failed', originalDims, originalBytes };
108+
}
109+
const newDims = await getDimensions(path, deps);
110+
const newBytes = fileSize(path);
111+
return {
112+
resized: true,
113+
path,
114+
originalDims,
115+
newDims: newDims ?? undefined,
116+
originalBytes,
117+
newBytes,
118+
};
119+
}

scripts/cdp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent-cdp",
3-
"version": "0.20.1",
3+
"version": "0.21.0",
44
"type": "module",
55
"main": "dist/index.js",
66
"scripts": {

scripts/cdp-bridge/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,11 +453,13 @@ trackedTool(
453453

454454
trackedTool(
455455
'device_screenshot',
456-
'Capture a screenshot of the active device screen. Returns image data or file path. Prefer JPEG for faster capture. When both iOS sim and Android emulator are booted, defaults to the platform of the currently connected CDP target; override with `platform` if needed.',
456+
'Capture a screenshot of the active device screen. Returns the file path. Prefer JPEG for faster capture. When both iOS sim and Android emulator are booted, defaults to the platform of the currently connected CDP target. Output is auto-downscaled to maxWidth (default 800px) via macOS sips to keep LLM context costs predictable; pass maxWidth=0 to disable when full-resolution capture is needed (visual diffing). meta.resize describes what happened.',
457457
{
458458
path: z.string().optional().describe('Output file path (default: auto-generated in /tmp). Use .jpg extension for JPEG.'),
459459
format: z.enum(['jpeg', 'png']).optional().describe('Image format (default: auto-detect from path extension, or jpeg)'),
460460
platform: z.enum(['ios', 'android']).optional().describe('Target device platform. Defaults to the currently-connected CDP target platform.'),
461+
maxWidth: z.number().int().min(0).optional().describe('Downscale image so width does not exceed this many pixels. 0 disables resize. Default 800 (saves ~46% on iPhone 15/17 Pro screenshots without losing label readability).'),
462+
quality: z.number().int().min(1).max(100).optional().describe('JPEG compression quality (1-100). Only applied to .jpg/.jpeg files. Default 85.'),
461463
},
462464
createDeviceScreenshotHandler(getClient),
463465
);

0 commit comments

Comments
 (0)