Skip to content

Commit a5b7735

Browse files
committed
feat: 13 bug fixes, cdp_set_shared_value tool, 148 tests (v0.22.0)
Benchmark-driven fixes from Liquid Glass 3-story validation: Bug fixes: - B103: cdp_navigate now verifies route arrival after fallback dispatch - B106: device_scroll/swipe routes through fast-runner HID synthesis - B107: deviceId parsing supports agent-device v0.8.0 response shape - R1: Phase 5.5 auto-opens device session (skill update) - R2: device_screenshot copies to requested output path - R3: fast-runner process liveness check + auto-restart on death - R4: UDID regex validation before ensureFastRunner - R5: scroll amount semantics aligned between fast-runner and daemon - R6: reload counter + NativeWind corruption warning in cdp_status - R8: MCP-only proof capture enforcement in skill boundaries New tool: - cdp_set_shared_value: drive Reanimated animations by testID for proof captures when gesture synthesis is unavailable (D623) Tests: 139 → 148 (+9 deviceId parsing unit tests) Security: hono 4.12.12 → 4.12.14 (Dependabot #5) Versions: plugin 0.22.0, MCP server 0.17.0 Decisions: D610-D625
1 parent b4d4078 commit a5b7735

22 files changed

Lines changed: 510 additions & 30 deletions

.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.21.1",
12+
"version": "0.22.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.21.1",
3+
"version": "0.22.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/agent-device-wrapper.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { execFile as execFileCb } from 'node:child_process';
22
import { promisify } from 'node:util';
3-
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
3+
import { readFileSync, writeFileSync, unlinkSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
44
import { createConnection } from 'node:net';
5-
import { join } from 'node:path';
5+
import { join, dirname } from 'node:path';
66
import { homedir } from 'node:os';
77
import { okResult, failResult } from './utils.js';
88
import { isFastRunnerAvailable, getFastRunnerState, fastTap, fastType, fastSwipe, fastSnapshot, fastScreenshot, fastDismissKeyboard, startFastRunner, } from './fast-runner-session.js';
99
import { updateRefMap, refCenter, getScreenRect, hasRefMap, clearRefMap } from './fast-runner-ref-map.js';
10+
import { resolveBundleId } from './project-config.js';
1011
const execFile = promisify(execFileCb);
1112
const SESSION_FILE = '/tmp/rn-dev-agent-session.json';
1213
const EXEC_TIMEOUT = 30_000;
@@ -197,15 +198,36 @@ function computeSwipeCoords(direction, screen) {
197198
}
198199
}
199200
async function tryFastRunner(command, positionals) {
200-
if (!isFastRunnerAvailable())
201-
return null;
201+
if (!isFastRunnerAvailable()) {
202+
const session = getActiveSession();
203+
if (session?.platform === 'ios' && session.deviceId) {
204+
try {
205+
await startFastRunner(session.deviceId, resolveBundleId('ios') ?? 'unknown');
206+
}
207+
catch { /* auto-restart failed */ }
208+
if (!isFastRunnerAvailable())
209+
return null;
210+
}
211+
else {
212+
return null;
213+
}
214+
}
202215
const state = getFastRunnerState();
203216
try {
204217
switch (command) {
205218
case 'screenshot': {
206219
const pngBuffer = await fastScreenshot();
207220
const tmpPath = `/tmp/rn-fast-screenshot-${Date.now()}.png`;
208221
writeFileSync(tmpPath, pngBuffer);
222+
const requestedPath = positionals.find(p => !p.startsWith('-'));
223+
if (requestedPath && requestedPath !== tmpPath) {
224+
try {
225+
mkdirSync(dirname(requestedPath), { recursive: true });
226+
copyFileSync(tmpPath, requestedPath);
227+
return okResult({ path: requestedPath, method: 'fast-runner' });
228+
}
229+
catch { /* fall through to tmpPath */ }
230+
}
209231
return okResult({ path: tmpPath, method: 'fast-runner' });
210232
}
211233
case 'snapshot': {

scripts/cdp-bridge/dist/fast-runner-session.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,19 @@ export function getFastRunnerState() {
2626
return runnerState;
2727
}
2828
export function isFastRunnerAvailable() {
29-
return runnerState !== null;
29+
if (!runnerState)
30+
return false;
31+
try {
32+
process.kill(runnerState.pid, 0);
33+
return true;
34+
}
35+
catch { /* process dead */ }
36+
runnerState = null;
37+
try {
38+
unlinkSync(STATE_FILE);
39+
}
40+
catch { /* ignore */ }
41+
return false;
3042
}
3143
// --- Lifecycle ---
3244
export function startFastRunner(deviceId, bundleId, port = DEFAULT_PORT) {

scripts/cdp-bridge/dist/index.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,71 @@ trackedTool('cdp_component_state', 'Inspect a specific component\'s full hook st
172172
}
173173
return okResult(parsed);
174174
}));
175+
trackedTool('cdp_set_shared_value', 'Set a Reanimated SharedValue on a component found by testID. Walks the React fiber tree to find the component, locates the named prop (a SharedValue object), and sets .value. Useful for driving Reanimated animations in proof captures when gesture/scroll synthesis is unavailable.', {
176+
testID: z.string().describe('testID of the component that receives the SharedValue as a prop'),
177+
prop: z.string().describe('Prop name containing the SharedValue (e.g. "scrollY", "progress")'),
178+
value: z.number().describe('Numeric value to set on the SharedValue'),
179+
}, withConnection(getClient, async (args, client) => {
180+
const expression = `(function() {
181+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
182+
if (!hook) return JSON.stringify({ __agent_error: 'No React DevTools hook' });
183+
var ids = Array.from(hook.renderers.keys());
184+
var allRoots = [];
185+
for (var i = 0; i < ids.length; i++) {
186+
var r = hook.getFiberRoots(ids[i]);
187+
if (r && r.size) { var it = r.values(); var v; while (!(v = it.next()).done) allRoots.push(v.value); }
188+
}
189+
if (!allRoots.length) return JSON.stringify({ __agent_error: 'No fiber roots' });
190+
var found = null;
191+
function walk(fiber, depth) {
192+
if (!fiber || depth > 300 || found) return;
193+
var props = fiber.memoizedProps;
194+
if (props && props.testID === ${JSON.stringify(args.testID)}) {
195+
var sv = props[${JSON.stringify(args.prop)}];
196+
if (sv && typeof sv === 'object' && 'value' in sv) { found = fiber; return; }
197+
var fc = fiber;
198+
for (var up = 0; up < 5 && fc; up++) {
199+
var p2 = fc.memoizedProps;
200+
if (p2 && p2[${JSON.stringify(args.prop)}] && typeof p2[${JSON.stringify(args.prop)}] === 'object' && 'value' in p2[${JSON.stringify(args.prop)}]) {
201+
found = fc; return;
202+
}
203+
fc = fc.return;
204+
}
205+
}
206+
if (fiber.child) walk(fiber.child, depth + 1);
207+
if (fiber.sibling) walk(fiber.sibling, depth);
208+
}
209+
for (var ri = 0; ri < allRoots.length; ri++) walk(allRoots[ri].current, 0);
210+
if (!found) return JSON.stringify({ __agent_error: 'No component with testID=' + ${JSON.stringify(args.testID)} + ' has a SharedValue prop named ' + ${JSON.stringify(args.prop)} });
211+
var sv = found.memoizedProps[${JSON.stringify(args.prop)}];
212+
if (!sv) {
213+
var fc2 = found;
214+
for (var up2 = 0; up2 < 5 && fc2; up2++) {
215+
if (fc2.memoizedProps && fc2.memoizedProps[${JSON.stringify(args.prop)}]) { sv = fc2.memoizedProps[${JSON.stringify(args.prop)}]; break; }
216+
fc2 = fc2.return;
217+
}
218+
}
219+
if (!sv || typeof sv !== 'object' || !('value' in sv)) return JSON.stringify({ __agent_error: 'SharedValue prop found but not accessible on the resolved fiber' });
220+
sv.value = ${args.value};
221+
return JSON.stringify({ ok: true, testID: ${JSON.stringify(args.testID)}, prop: ${JSON.stringify(args.prop)}, value: ${args.value} });
222+
})()`;
223+
const result = await client.evaluate(expression);
224+
if (result.error)
225+
return failResult(`SharedValue error: ${result.error}`);
226+
if (typeof result.value !== 'string')
227+
return failResult('Unexpected response');
228+
let parsed;
229+
try {
230+
parsed = JSON.parse(result.value);
231+
}
232+
catch {
233+
return okResult({ raw: result.value });
234+
}
235+
if (parsed !== null && typeof parsed === 'object' && '__agent_error' in parsed) {
236+
return failResult(String(parsed.__agent_error));
237+
}
238+
return okResult(parsed);
239+
}));
175240
trackedTool('cdp_dispatch', 'Dispatch a Redux action and optionally read state afterward — all in a single synchronous JS execution. Use for atomic dispatch+verify operations (e.g. dispatch "tasks/softDelete" then read "tasks.pendingDelete"). NOTE: Best used for state verification, not UI interaction testing — React components may not re-render immediately after CDP-dispatched actions. For UI testing, use device_press/device_find to trigger the action through the UI instead.', {
176241
action: z.string().describe('Redux action type (e.g. "tasks/softDelete", "cart/addItem")'),
177242
payload: z.any().optional().describe('Action payload'),

scripts/cdp-bridge/dist/injected-helpers.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,22 @@ export const INJECTED_HELPERS = `
10661066
}
10671067
10681068
ref.navigate(screen, params || undefined);
1069+
var afterFallback = ref.getRootState();
1070+
var found = false;
1071+
function checkRoute(rs) {
1072+
if (!rs) return;
1073+
if (rs.name === screen) { found = true; return; }
1074+
if (rs.routes) {
1075+
for (var ri = 0; ri < rs.routes.length; ri++) {
1076+
checkRoute(rs.routes[ri]);
1077+
if (rs.routes[ri].state) checkRoute(rs.routes[ri].state);
1078+
}
1079+
}
1080+
}
1081+
checkRoute(afterFallback);
1082+
if (!found) {
1083+
return JSON.stringify({ __agent_error: 'Navigate failed: screen "' + screen + '" not found in any navigator after dispatch. Check screen name spelling and that it is registered in a navigator.' });
1084+
}
10691085
return JSON.stringify({ navigated: true, screen: screen, method: 'fallback-navigate' });
10701086
10711087
} catch(e) {

scripts/cdp-bridge/dist/tools/device-interact.js

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFile as execFileCb } from 'node:child_process';
22
import { promisify } from 'node:util';
33
import { runAgentDevice, getActiveSession, getCachedScreenRect, getAdbSerial, cacheSnapshot } from '../agent-device-wrapper.js';
4+
import { isFastRunnerAvailable, fastSwipe } from '../fast-runner-session.js';
45
import { withSession } from '../utils.js';
56
import { okResult, failResult } from '../utils.js';
67
import { runMaestroInline, yamlEscape } from '../maestro-invoke.js';
@@ -315,8 +316,22 @@ function computeSwipeFromDirection(direction, screen) {
315316
}
316317
}
317318
export function createDeviceSwipeHandler() {
318-
return withSession((args) => {
319+
return withSession(async (args) => {
320+
// B106 fix: use fast-runner's HID-level synthesis to bypass XCTest
321+
// `waitForIdle` hangs on Reanimated-driven screens. Only applies when
322+
// fast-runner is available (iOS) and count/pattern are not used (those
323+
// are daemon-specific features — fall back to agent-device for them).
324+
const canUseFastRunner = isFastRunnerAvailable() && !args.count && !args.pattern;
319325
if (args.x1 != null && args.y1 != null && args.x2 != null && args.y2 != null) {
326+
if (canUseFastRunner) {
327+
try {
328+
const resp = await fastSwipe(args.x1, args.y1, args.x2, args.y2, args.durationMs);
329+
if (resp.ok) {
330+
return okResult({ x1: args.x1, y1: args.y1, x2: args.x2, y2: args.y2, durationMs: args.durationMs, method: 'fast-runner' });
331+
}
332+
}
333+
catch { /* fall through */ }
334+
}
320335
const cliArgs = ['swipe', String(args.x1), String(args.y1), String(args.x2), String(args.y2)];
321336
if (args.durationMs)
322337
cliArgs.push(String(args.durationMs));
@@ -328,23 +343,75 @@ export function createDeviceSwipeHandler() {
328343
}
329344
if (args.direction) {
330345
// B-Tier3 fix: Use real swipe gesture (not scroll) for direction-based swipes.
331-
// The previous delegation to `scroll` produced smooth list scrolls that don't
332-
// trigger gesture handlers (pull-to-refresh, swipe-to-delete).
333346
const screen = getCachedScreenRect() ?? DEFAULT_SCREEN;
334347
const coords = computeSwipeFromDirection(args.direction, screen);
335348
const duration = args.durationMs ?? DEFAULT_SWIPE_DURATION_MS;
349+
if (canUseFastRunner) {
350+
try {
351+
const resp = await fastSwipe(coords.x1, coords.y1, coords.x2, coords.y2, duration);
352+
if (resp.ok) {
353+
return okResult({ direction: args.direction, durationMs: duration, method: 'fast-runner', ...coords });
354+
}
355+
}
356+
catch { /* fall through */ }
357+
}
336358
const cliArgs = ['swipe', String(coords.x1), String(coords.y1), String(coords.x2), String(coords.y2), String(duration)];
337359
if (args.count && args.count > 1)
338360
cliArgs.push('--count', String(args.count));
339361
if (args.pattern)
340362
cliArgs.push('--pattern', args.pattern);
341363
return runAgentDevice(cliArgs);
342364
}
343-
return Promise.resolve(failResult('Provide either direction or x1,y1,x2,y2 coordinates'));
365+
return failResult('Provide either direction or x1,y1,x2,y2 coordinates');
344366
});
345367
}
346368
export function createDeviceScrollHandler() {
347-
return withSession((args) => {
369+
return withSession(async (args) => {
370+
// B106 fix: Route iOS scroll through fast-runner's direct HID synthesis
371+
// when available. The agent-device daemon path uses XCTest's high-level
372+
// gesture API which calls `waitForIdle` after the drag — this hangs
373+
// indefinitely on screens driven by Reanimated `useAnimatedScrollHandler`
374+
// because the UI thread is never "idle" between scroll events. Fast-runner
375+
// uses `RunnerDaemonProxy.synthesize(eventRecord)` which is raw HID event
376+
// injection and returns as soon as events are delivered.
377+
if (isFastRunnerAvailable()) {
378+
const screen = getCachedScreenRect() ?? DEFAULT_SCREEN;
379+
const amount = Math.min(Math.max(args.amount ?? 0.5, 0), 1);
380+
const cx = Math.round(screen.width / 2);
381+
const cy = Math.round(screen.height / 2);
382+
const dy = Math.round(screen.height * SWIPE_FRACTION * amount);
383+
const dx = Math.round(screen.width * SWIPE_FRACTION * amount);
384+
let x1 = cx, y1 = cy, x2 = cx, y2 = cy;
385+
switch (args.direction) {
386+
// "scroll down" = content moves up = finger moves up (swipe up)
387+
case 'down':
388+
y1 = cy + Math.round(dy / 2);
389+
y2 = cy - Math.round(dy / 2);
390+
break;
391+
case 'up':
392+
y1 = cy - Math.round(dy / 2);
393+
y2 = cy + Math.round(dy / 2);
394+
break;
395+
case 'left':
396+
x1 = cx + Math.round(dx / 2);
397+
x2 = cx - Math.round(dx / 2);
398+
break;
399+
case 'right':
400+
x1 = cx - Math.round(dx / 2);
401+
x2 = cx + Math.round(dx / 2);
402+
break;
403+
}
404+
try {
405+
const resp = await fastSwipe(x1, y1, x2, y2, DEFAULT_SWIPE_DURATION_MS);
406+
if (resp.ok) {
407+
return okResult({ direction: args.direction, amount: args.amount ?? 0.5, method: 'fast-runner', x1, y1, x2, y2 });
408+
}
409+
// Fall through to daemon on fast-runner failure
410+
}
411+
catch {
412+
// Fall through to daemon on fast-runner error
413+
}
414+
}
348415
const cliArgs = ['scroll', args.direction];
349416
if (args.amount != null)
350417
cliArgs.push(String(args.amount));

scripts/cdp-bridge/dist/tools/device-session.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,19 @@ export function createDeviceSnapshotHandler() {
3232
let deviceId;
3333
try {
3434
const envelope = JSON.parse(result.content[0].text);
35-
deviceId = envelope?.data?.deviceId ?? envelope?.data?.device?.id;
35+
const data = envelope?.data;
36+
// agent-device `open` response shape (v0.8.0):
37+
// data.id = device UDID (top-level)
38+
// data.device_udid = UDID (duplicate)
39+
// data.device = device NAME (string, e.g. "iPhone 17 Pro") — NOT an object
40+
// data.deviceId = legacy field (older agent-device)
41+
// B107 fix: also read data.id / data.device_udid / (data.device.id when object).
42+
const rawId = data?.deviceId
43+
?? data?.device_udid
44+
?? data?.id
45+
?? (typeof data?.device === 'object' ? data?.device?.id : undefined);
46+
const UDID_RE = /^[0-9A-Fa-f-]{25,}$/;
47+
deviceId = typeof rawId === 'string' && UDID_RE.test(rawId) ? rawId : undefined;
3648
}
3749
catch { /* best-effort */ }
3850
setActiveSession({

scripts/cdp-bridge/dist/tools/reload.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { okResult, failResult, warnResult, withConnection } from '../utils.js';
2+
let sessionReloadCount = 0;
3+
export function getSessionReloadCount() { return sessionReloadCount; }
24
export function createReloadHandler(getClient) {
35
return withConnection(getClient, async (_args, client) => {
46
// Step 1: Trigger reload — expected to disconnect the WS
@@ -79,6 +81,7 @@ export function createReloadHandler(getClient) {
7981
return warnResult({ reloaded: true, type: 'full', reconnected: true }, 'Reload succeeded but helper injection failed. App may still be loading — retry cdp_status.');
8082
}
8183
}
84+
sessionReloadCount++;
8285
return okResult({ reloaded: true, type: 'full', reconnected: true });
8386
});
8487
}

scripts/cdp-bridge/dist/tools/status.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { okResult, failResult, warnResult } from '../utils.js';
22
import { handleDevClientPicker } from './dev-client-picker.js';
3+
import { getSessionReloadCount } from './reload.js';
34
const STATUS_PROBE_EXPRESSION = `
45
(function() {
56
var result = { appInfo: null, errorCount: 0, fiberTree: false, hasRedBox: false, helpersLoaded: false };
@@ -145,6 +146,10 @@ export function createStatusHandler(getClient, setClient, createClient) {
145146
return warnResult(status, 'Debugger is paused. Auto-recovery failed. Try cdp_reload(full=true).');
146147
}
147148
}
149+
const reloadCount = getSessionReloadCount();
150+
if (reloadCount >= 5) {
151+
return warnResult(status, `${reloadCount} full reloads in this session. NativeWind stylesheet may be corrupted — if the screen appears blank, restart Metro and relaunch the app.`);
152+
}
148153
return okResult(status, autoRecoveredMessage ? { meta: { autoRecovered: autoRecoveredMessage } } : undefined);
149154
}
150155
catch (err) {

0 commit comments

Comments
 (0)