Skip to content
Merged
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 .changeset/ios-runner-self-build-and-gate-agent-device.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"rn-dev-agent-plugin": patch
---

Fix iOS runner auto-install and stop force-installing agent-device on iOS-only setups.

- **rn-fast-runner now self-builds on first use.** `startFastRunner()` falls back to a full `xcodebuild test` (build + test) when no prebuilt `.xctestrun` exists, instead of always using `test-without-building` (which failed on a fresh machine where `build/DerivedData` is gitignored and never produced). The first `device_snapshot action=open` on a clean clone now succeeds — it just cold-builds the rig once (ready-signal timeout widened to 360s for that path). Steady-state spawns still use the fast `test-without-building`.
- **agent-device install is gated on a live Android target.** The SessionStart hook (`detect-rn-project.sh`) no longer runs `npm install -g agent-device` unconditionally. Since D1219/PR #164 iOS device control is owned by the in-tree rn-fast-runner, so agent-device is Android-only; the install now only runs when `adb devices` shows a booted device/emulator. iOS-only macOS users stop paying for a dependency they never use.
- `/setup` and `/doctor` now offer to run the one-time `xcodebuild build-for-testing` pre-build to move the cold-build cost out of the first interaction (the lazy fallback covers correctness; pre-building just avoids the slow first call).
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Development scaffolding lives in the **sibling workspace repo**:
- **iOS Simulator** booted with your app OR **Android Emulator** running
- **Metro dev server** running (`npx expo start` or `npx react-native start`)
- Platform-specific device-control runtime (one of):
- **iOS** — in-tree `rn-fast-runner` XCTest project ships with the plugin (`scripts/rn-fast-runner/`). Pre-build it once with a booted simulator: `cd ${CLAUDE_PLUGIN_ROOT}/scripts/rn-fast-runner/RnFastRunner && xcodebuild build-for-testing -project RnFastRunner.xcodeproj -scheme RnFastRunner -destination "platform=iOS Simulator,id=<UDID>" -derivedDataPath ../build/DerivedData`. After that, the runner spawns lazily on the first `device_snapshot action=open`. iOS no longer requires `agent-device` (PR #164 / D1219).
- **iOS** — in-tree `rn-fast-runner` XCTest project ships with the plugin (`scripts/rn-fast-runner/`). It **self-builds on first use**: the first `device_snapshot action=open` runs `xcodebuild test` (build + test) when no prebuilt `.xctestrun` exists, then `test-without-building` thereafter — so a fresh machine works with no manual step (the first call cold-builds, which takes several minutes). To skip that one-time wait, optionally pre-build with a booted simulator: `cd ${CLAUDE_PLUGIN_ROOT}/scripts/rn-fast-runner/RnFastRunner && xcodebuild build-for-testing -project RnFastRunner.xcodeproj -scheme RnFastRunner -destination "platform=iOS Simulator,id=<UDID>" -derivedDataPath ../build/DerivedData`. iOS no longer requires `agent-device` (PR #164 / D1219).
- **Android** — `agent-device` CLI: `npm install -g agent-device` (auto-installed by the plugin; may need manual install if the auto-install fails).
- `maestro-runner` — auto-installed to `~/.maestro-runner/`

Expand Down Expand Up @@ -85,8 +85,9 @@ Experience Engine (cross-session learning):
### Troubleshooting
- **"CDP connection failed"** → Is Metro running? Is the app loaded on the simulator?
- **"agent-device not installed"** → Only required for Android. If targeting Android, run `npm install -g agent-device`. iOS uses the in-tree `rn-fast-runner` and does not need it.
- **"rn-fast-runner did not become ready" / no `.xctestrun` at the expected path** → Pre-build the runner once with `xcodebuild build-for-testing` (see Prerequisites). The build artifacts live at `scripts/rn-fast-runner/build/DerivedData/`.
- **"rn-fast-runner did not become ready" / no `.xctestrun` at the expected path** → The runner self-builds on first use (cold `xcodebuild test`), so this now usually means the cold build itself timed out or failed — not a missing prebuild. The cold-build ready timeout is 360s; a slower machine or a build error (check Xcode/simulator state) can still trip it. To take the build out of the hot path, pre-build once with `xcodebuild build-for-testing` (see Prerequisites). The build artifacts live at `scripts/rn-fast-runner/build/DerivedData/`.
- **Legacy `AgentDeviceRunner` re-appears on the simulator** → A stale `~/.agent-device/daemon.json` is respawning the upstream runner. Either run with `RN_DEVICE_KILL_LEGACY=1` (the plugin terminates the daemon at session-open) or `pkill -f AgentDeviceRunner && rm -f ~/.agent-device/daemon.json ~/.agent-device/daemon.lock` one-time.
- **`RnFastRunner` / `RnFastRunnerUITests-Runner` icons appear on the simulator** → Expected, not clutter. iOS device control is an XCUITest rig (D1219), so running it installs two apps: `RnFastRunner` (the minimal host app, bundle `dev.lykhoyda.rndevagent.fastrunner`) and `RnFastRunnerUITests-Runner` (the XCUITest harness — same pattern as WebDriverAgent's `WebDriverAgentRunner`). The Runner hosts the `POST /command` HTTP server on port 22088 and drives YOUR app via `XCUIApplication(bundleIdentifier:)` — it never drives itself. It stays installed/running on purpose so subsequent `device_*` calls are fast; leave it. (Contrast the legacy `AgentDeviceRunner` above, which IS unwanted.)
- **"No booted simulator"** → Open Simulator.app or boot one via Xcode
- **iOS 26.x beta issues** → Use iOS 18 stable runtime (Xcode > Settings > Platforms)
- **Node.js odd version (v25)** → Switch to Node 22 LTS: `nvm install 22 && nvm use 22`
Expand Down
2 changes: 1 addition & 1 deletion commands/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ iOS device automation is owned by the in-tree `rn-fast-runner` XCTest project (D

**This command is read-only.** It diagnoses the current environment and recommends fixes. It does NOT modify any files in the user's project, inject documentation, or instrument source code.

Present results as a 14-row table. For any RED rows, give the user the exact install command (with `nvm` / `sudo` / `brew` flag selection based on their environment). For the `rn-fast-runner` row: if NEEDS_BUILD, surface the one-time `xcodebuild build-for-testing` command (the rn-setup skill section 3 has the exact form). For the helpers row specifically, do NOT suggest retrying `cdp_status` — the bridge already auto-retried injection. If MISSING, recommend `device_*` fallbacks or `cdp_reload`. For the **plugin version** row: if BEHIND, surface `/plugin update rn-dev-agent` and let the user decide whether to update before continuing. If OFFLINE (GitHub unreachable), skip without failing — plugin works fine without the upstream check. For the **Vercel rules sync** row: if STALE (>30 days since last sync) or MISSING (no `third_party/vercel-labs/agent-skills/UPSTREAM.lock.json`), surface the resync command — do NOT auto-run; the user runs `node ${CLAUDE_PLUGIN_ROOT}/scripts/sync-vercel-skills.mjs --ref <sha>` themselves.
Present results as a 14-row table. For any RED rows, give the user the exact install command (with `nvm` / `sudo` / `brew` flag selection based on their environment). For the `rn-fast-runner` row: if NEEDS_BUILD, note that the runner self-builds on first use (a slow cold build) and OFFER to run the one-time `xcodebuild build-for-testing` now to move that cost out of the first interaction (the rn-setup skill section 3 has the exact form + the offer prompt). Building the runner writes only to plugin-internal `scripts/rn-fast-runner/build/` — it does not touch the user's project, so the offer is consistent with this command's read-only-on-your-project contract. For the helpers row specifically, do NOT suggest retrying `cdp_status` — the bridge already auto-retried injection. If MISSING, recommend `device_*` fallbacks or `cdp_reload`. For the **plugin version** row: if BEHIND, surface `/plugin update rn-dev-agent` and let the user decide whether to update before continuing. If OFFLINE (GitHub unreachable), skip without failing — plugin works fine without the upstream check. For the **Vercel rules sync** row: if STALE (>30 days since last sync) or MISSING (no `third_party/vercel-labs/agent-skills/UPSTREAM.lock.json`), surface the resync command — do NOT auto-run; the user runs `node ${CLAUDE_PLUGIN_ROOT}/scripts/sync-vercel-skills.mjs --ref <sha>` themselves.

If the user wants the plugin to also inject project instructions (CLAUDE.md template, nav-ref, store exposure) — point them at `/rn-dev-agent:setup` instead.
13 changes: 10 additions & 3 deletions hooks/detect-rn-project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,16 @@ if [ "$has_rn_config" = true ]; then
INSTALL_WARNINGS+=("WARNING: maestro-runner not installed. Run: npm install -g maestro-runner")
fi

# Ensure agent-device is installed (stderr visible for diagnostics)
if ! bash "$PLUGIN_ROOT/scripts/ensure-agent-device.sh" 2>&1; then
INSTALL_WARNINGS+=("WARNING: agent-device not installed. Run: npm install -g agent-device")
# Ensure agent-device is installed — Android targets only (stderr visible for
# diagnostics). Since D1219/PR #164 iOS device control is owned by the in-tree
# rn-fast-runner, so agent-device is Android-only. Gate the global npm install
# on a live Android device/emulator so iOS-only macOS users don't pay for a
# dependency they never use. Android users without a booted emulator at
# SessionStart can still install it via /rn-dev-agent:setup.
if command -v adb &>/dev/null && adb devices 2>/dev/null | grep -q "device$"; then
if ! bash "$PLUGIN_ROOT/scripts/ensure-agent-device.sh" 2>&1; then
INSTALL_WARNINGS+=("WARNING: agent-device not installed. Run: npm install -g agent-device")
fi
fi

# GH #59 #3: surface (and optionally auto-install) idb-companion when a
Expand Down
61 changes: 50 additions & 11 deletions scripts/cdp-bridge/dist/runners/rn-fast-runner-client.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import { writeFileSync, unlinkSync, readFileSync, existsSync } from 'node:fs';
import { writeFileSync, unlinkSync, readFileSync, existsSync, readdirSync } from 'node:fs';
import { okResult, failResult } from '../utils.js';
import { updateRefMapFromFlat, getCachedMetadata } from '../fast-runner-ref-map.js';
const DEFAULT_PORT = 22088;
const READY_TIMEOUT_MS = 30_000;
// A cold `xcodebuild test` compiles the runner project before launching it; on a
// fresh machine (no prebuilt .xctestrun) that can take several minutes, so the
// ready-signal timeout is widened for the build path.
const BUILD_READY_TIMEOUT_MS = 360_000;
const HTTP_TIMEOUT_MS = 10_000;
const STATE_FILE = '/tmp/rn-fast-runner-state.json';
const FAST_RUNNER_PROJECT = join(import.meta.dirname, '..', '..', '..', 'rn-fast-runner');
Expand Down Expand Up @@ -95,6 +99,38 @@ export function isFastRunnerAvailable() {
catch { /* ignore */ }
return false;
}
/**
* Decide which xcodebuild invocation launches the runner.
*
* `test-without-building` is the fast steady-state path, but it requires a prior
* `build-for-testing` to have produced a .xctestrun. On a fresh machine that
* artifact is absent (build/ is gitignored), so we fall back to a full `test`,
* which compiles the project first and then runs it — making the runner
* self-install on first use (D1219 follow-up).
*/
export function resolveRunnerXcodebuildArgs(opts) {
const action = opts.hasBuiltTestProduct ? 'test-without-building' : 'test';
return [
action,
'-project', opts.projectPath,
'-scheme', opts.scheme,
'-destination', `platform=iOS Simulator,id=${opts.deviceId}`,
'-derivedDataPath', opts.derivedDataPath,
`-only-testing:${opts.onlyTesting}`,
];
}
/** True when a prior build-for-testing left a .xctestrun under DerivedData. */
export function hasBuiltTestProduct(derivedDataPath) {
try {
const productsDir = join(derivedDataPath, 'Build', 'Products');
if (!existsSync(productsDir))
return false;
return readdirSync(productsDir).some((entry) => entry.endsWith('.xctestrun'));
}
catch {
return false;
}
}
// --- Lifecycle ---
export function startFastRunner(deviceId, bundleId, port = DEFAULT_PORT) {
if (runnerState)
Expand All @@ -106,14 +142,15 @@ export function startFastRunner(deviceId, bundleId, port = DEFAULT_PORT) {
return;
}
const derivedDataPath = join(FAST_RUNNER_PROJECT, 'build', 'DerivedData');
const args = [
'test-without-building',
'-project', projectPath,
'-scheme', 'RnFastRunner',
'-destination', `platform=iOS Simulator,id=${deviceId}`,
'-derivedDataPath', derivedDataPath,
'-only-testing:RnFastRunnerUITests/RnFastRunnerTests/testCommand',
];
const built = hasBuiltTestProduct(derivedDataPath);
const args = resolveRunnerXcodebuildArgs({
projectPath,
scheme: 'RnFastRunner',
deviceId,
derivedDataPath,
onlyTesting: 'RnFastRunnerUITests/RnFastRunnerTests/testCommand',
hasBuiltTestProduct: built,
});
const child = spawn('xcodebuild', args, {
env: {
...process.env,
Expand All @@ -124,10 +161,12 @@ export function startFastRunner(deviceId, bundleId, port = DEFAULT_PORT) {
runnerProcess = child;
const parser = createReadySignalParser();
let resolved = false;
const readyTimeoutMs = built ? READY_TIMEOUT_MS : BUILD_READY_TIMEOUT_MS;
const timer = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`Fast runner did not become ready within ${READY_TIMEOUT_MS / 1000}s`));
}, READY_TIMEOUT_MS);
const phase = built ? '' : ' (cold build — first run compiles the runner)';
reject(new Error(`Fast runner did not become ready within ${readyTimeoutMs / 1000}s${phase}`));
}, readyTimeoutMs);
const handleChunk = (chunk) => {
if (resolved)
return;
Expand Down
72 changes: 61 additions & 11 deletions scripts/cdp-bridge/src/runners/rn-fast-runner-client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import { writeFileSync, unlinkSync, readFileSync, existsSync } from 'node:fs';
import { writeFileSync, unlinkSync, readFileSync, existsSync, readdirSync } from 'node:fs';
import type { ToolResult } from '../utils.js';
import { okResult, failResult } from '../utils.js';
import type { FastRunnerState } from '../types.js';
import { updateRefMapFromFlat, getCachedMetadata, type FlatNode } from '../fast-runner-ref-map.js';

const DEFAULT_PORT = 22088;
const READY_TIMEOUT_MS = 30_000;
// A cold `xcodebuild test` compiles the runner project before launching it; on a
// fresh machine (no prebuilt .xctestrun) that can take several minutes, so the
// ready-signal timeout is widened for the build path.
const BUILD_READY_TIMEOUT_MS = 360_000;
const HTTP_TIMEOUT_MS = 10_000;
const STATE_FILE = '/tmp/rn-fast-runner-state.json';
const FAST_RUNNER_PROJECT = join(import.meta.dirname, '..', '..', '..', 'rn-fast-runner');
Expand Down Expand Up @@ -121,6 +125,49 @@ export function isFastRunnerAvailable(): boolean {
return false;
}

// --- Build-vs-spawn decision (pure, testable without xcodebuild) ---

interface RunnerXcodebuildArgsOptions {
projectPath: string;
scheme: string;
deviceId: string;
derivedDataPath: string;
onlyTesting: string;
hasBuiltTestProduct: boolean;
}

/**
* Decide which xcodebuild invocation launches the runner.
*
* `test-without-building` is the fast steady-state path, but it requires a prior
* `build-for-testing` to have produced a .xctestrun. On a fresh machine that
* artifact is absent (build/ is gitignored), so we fall back to a full `test`,
* which compiles the project first and then runs it — making the runner
* self-install on first use (D1219 follow-up).
*/
export function resolveRunnerXcodebuildArgs(opts: RunnerXcodebuildArgsOptions): string[] {
const action = opts.hasBuiltTestProduct ? 'test-without-building' : 'test';
return [
action,
'-project', opts.projectPath,
'-scheme', opts.scheme,
'-destination', `platform=iOS Simulator,id=${opts.deviceId}`,
'-derivedDataPath', opts.derivedDataPath,
`-only-testing:${opts.onlyTesting}`,
];
}

/** True when a prior build-for-testing left a .xctestrun under DerivedData. */
export function hasBuiltTestProduct(derivedDataPath: string): boolean {
try {
const productsDir = join(derivedDataPath, 'Build', 'Products');
if (!existsSync(productsDir)) return false;
return readdirSync(productsDir).some((entry) => entry.endsWith('.xctestrun'));
} catch {
return false;
}
}

// --- Lifecycle ---

export function startFastRunner(deviceId: string, bundleId: string, port = DEFAULT_PORT): Promise<FastRunnerState> {
Expand All @@ -135,14 +182,15 @@ export function startFastRunner(deviceId: string, bundleId: string, port = DEFAU
}

const derivedDataPath = join(FAST_RUNNER_PROJECT, 'build', 'DerivedData');
const args = [
'test-without-building',
'-project', projectPath,
'-scheme', 'RnFastRunner',
'-destination', `platform=iOS Simulator,id=${deviceId}`,
'-derivedDataPath', derivedDataPath,
'-only-testing:RnFastRunnerUITests/RnFastRunnerTests/testCommand',
];
const built = hasBuiltTestProduct(derivedDataPath);
const args = resolveRunnerXcodebuildArgs({
projectPath,
scheme: 'RnFastRunner',
deviceId,
derivedDataPath,
onlyTesting: 'RnFastRunnerUITests/RnFastRunnerTests/testCommand',
hasBuiltTestProduct: built,
});

const child = spawn('xcodebuild', args, {
env: {
Expand All @@ -155,10 +203,12 @@ export function startFastRunner(deviceId: string, bundleId: string, port = DEFAU
runnerProcess = child;
const parser = createReadySignalParser();
let resolved = false;
const readyTimeoutMs = built ? READY_TIMEOUT_MS : BUILD_READY_TIMEOUT_MS;
const timer = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`Fast runner did not become ready within ${READY_TIMEOUT_MS / 1000}s`));
}, READY_TIMEOUT_MS);
const phase = built ? '' : ' (cold build — first run compiles the runner)';
reject(new Error(`Fast runner did not become ready within ${readyTimeoutMs / 1000}s${phase}`));
}, readyTimeoutMs);

const handleChunk = (chunk: string): void => {
if (resolved) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveRunnerXcodebuildArgs } from '../../../dist/runners/rn-fast-runner-client.js';

const BASE = {
projectPath: '/p/RnFastRunner.xcodeproj',
scheme: 'RnFastRunner',
deviceId: 'UDID-123',
derivedDataPath: '/p/build/DerivedData',
onlyTesting: 'RnFastRunnerUITests/RnFastRunnerTests/testCommand',
};

test('uses test-without-building when the test product is already built', () => {
const args = resolveRunnerXcodebuildArgs({ ...BASE, hasBuiltTestProduct: true });
assert.equal(args[0], 'test-without-building');
assert.ok(args.includes('-project'));
assert.ok(args.includes('/p/RnFastRunner.xcodeproj'));
assert.ok(args.includes('-destination'));
assert.ok(args.includes('platform=iOS Simulator,id=UDID-123'));
assert.ok(args.includes('-derivedDataPath'));
assert.ok(args.includes('/p/build/DerivedData'));
assert.ok(args.includes('-only-testing:RnFastRunnerUITests/RnFastRunnerTests/testCommand'));
});

test('falls back to a cold build+test when no test product exists yet (fresh machine)', () => {
const args = resolveRunnerXcodebuildArgs({ ...BASE, hasBuiltTestProduct: false });
assert.equal(args[0], 'test');
assert.ok(!args.includes('test-without-building'), 'must not use test-without-building without artifacts');
assert.ok(args.includes('-project'));
assert.ok(args.includes('-scheme'));
assert.ok(args.includes('RnFastRunner'));
assert.ok(args.includes('-destination'));
assert.ok(args.includes('-only-testing:RnFastRunnerUITests/RnFastRunnerTests/testCommand'));
});
Loading
Loading