diff --git a/README.md b/README.md index 2600d08ad..b83677228 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The project is in early development and considered experimental. Pull requests a ## Features - Platforms: iOS (simulator + limited device support) and Android (emulator + device). -- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`. +- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`. - Inspection commands: `snapshot` (accessibility tree). - Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode). - Minimal dependencies; TypeScript executed directly on Node 22+ (no build step). @@ -75,7 +75,7 @@ Coordinates: - X increases to the right, Y increases downward. ## Command Index -- `boot`, `open`, `close`, `home`, `back`, `app-switcher` +- `boot`, `open`, `close`, `reinstall`, `home`, `back`, `app-switcher` - `snapshot`, `find`, `get` - `click`, `focus`, `type`, `fill`, `press`, `long-press`, `scroll`, `scrollintoview`, `is` - `alert`, `wait`, `screenshot` @@ -123,6 +123,13 @@ Sessions: - Session scripts are written to `~/.agent-device/sessions/-.ad` when recording is enabled with `--save-script`. - Deterministic replay is `.ad`-based; use `replay --update` (`-u`) to update selector drift and rewrite the replay file in place. +Navigation helpers: +- `boot --platform ios|android` ensures the target is ready without launching an app. +- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available. +- `open [app]` already boots/activates the selected target when needed. +- `reinstall ` uninstalls and installs the app binary in one command (Android + iOS simulator in v1). +- `reinstall` accepts package/bundle id style app names and supports `~` in paths. + Find (semantic): - `find [value]` finds by any text (label/value/identifier) using a scoped snapshot. - `find text|label|value|role|id [value]` for specific locators. @@ -188,7 +195,7 @@ Boot diagnostics: - Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs. - Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`. - Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors. -- Use `agent-device boot --platform ios|android` for explicit CI preflight readiness checks. +- Use `agent-device boot --platform ios|android` when starting a new session only if `open` cannot find/connect to an available target. - Set `AGENT_DEVICE_RETRY_LOGS=1` to print structured retry telemetry (attempt, phase, delay, elapsed/remaining deadline, reason). ## App resolution diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index af5b77c36..2b12dd52d 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -27,7 +27,7 @@ npx -y agent-device ## Core workflow -1. Open app or just boot device: `open [app]` +1. Open app: `open [app]` (`open` handles target selection + boot/activation in the normal flow) 2. Snapshot: `snapshot` to get refs from accessibility tree 3. Interact using refs (`click @ref`, `fill @ref "text"`) 4. Re-snapshot after navigation/UI changes @@ -38,12 +38,19 @@ npx -y agent-device ### Navigation ```bash +agent-device boot # Ensure target is booted/ready without opening app +agent-device boot --platform ios # Boot iOS simulator +agent-device boot --platform android # Boot Android emulator/device target agent-device open [app] # Boot device/simulator; optionally launch app agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity agent-device close [app] # Close app or just end session +agent-device reinstall # Uninstall + install app in one command agent-device session list # List active sessions ``` +`boot` requires either an active session or an explicit selector (`--platform`, `--device`, `--udid`, or `--serial`). +`boot` is a fallback, not a regular step: use it when starting a new session only if `open` cannot find/connect to an available target. + ### Snapshot (page analysis) ```bash diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 5fb17506e..ecb04ebf5 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -47,6 +47,7 @@ test('iOS simulator + Android commands reject iOS devices', () => { 'home', 'long-press', 'open', + 'reinstall', 'press', 'record', 'screenshot', diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 199ffe90d..cb8d38a1b 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -30,6 +30,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { home: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, 'long-press': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, open: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, + reinstall: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, press: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, screenshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, diff --git a/src/daemon/handlers/__tests__/session-reinstall.test.ts b/src/daemon/handlers/__tests__/session-reinstall.test.ts new file mode 100644 index 000000000..5fe348e51 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -0,0 +1,219 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { handleSessionCommands } from '../session.ts'; +import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; + +function makeStore(): SessionStore { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-reinstall-')); + return new SessionStore(path.join(tempRoot, 'sessions')); +} + +function makeSession(name: string, device: SessionState['device']): SessionState { + return { + name, + device, + createdAt: Date.now(), + actions: [], + }; +} + +const invoke = async (_req: DaemonRequest): Promise => { + return { ok: false, error: { code: 'INVALID_ARGS', message: 'invoke should not be called in reinstall tests' } }; +}; + +test('reinstall requires active session or explicit device selector', async () => { + const sessionStore = makeStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.app', '/tmp/app.apk'], + flags: {}, + }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore, + invoke, + }); + assert.ok(response); + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /active session or an explicit device selector/i); + } +}); + +test('reinstall validates required args before device operations', async () => { + const sessionStore = makeStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.app'], + flags: {}, + }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore, + invoke, + }); + assert.ok(response); + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /reinstall /i); + } +}); + +test('reinstall reports unsupported operation on iOS physical devices', async () => { + const sessionStore = makeStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'ios', + id: 'device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + ); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-binary-')); + const appPath = path.join(tempRoot, 'Sample.app'); + fs.writeFileSync(appPath, 'placeholder'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.app', appPath], + flags: {}, + }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore, + invoke, + }); + assert.ok(response); + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'UNSUPPORTED_OPERATION'); + assert.match(response.error.message, /reinstall is not supported/i); + } +}); + +test('reinstall succeeds on active iOS simulator session and records action', async () => { + const sessionStore = makeStore(); + const session = makeSession('default', { + platform: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }); + sessionStore.set('default', session); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-success-ios-')); + const appPath = path.join(tempRoot, 'Sample.app'); + fs.writeFileSync(appPath, 'placeholder'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.app', appPath], + flags: {}, + }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore, + invoke, + reinstallOps: { + ios: async (_device, app, pathToBinary) => { + assert.equal(app, 'com.example.app'); + assert.equal(pathToBinary, appPath); + return { bundleId: 'com.example.app' }; + }, + android: async () => { + throw new Error('unexpected android reinstall'); + }, + }, + }); + + assert.ok(response); + assert.equal(response.ok, true); + if (response.ok) { + assert.equal(response.data?.platform, 'ios'); + assert.equal(response.data?.appId, 'com.example.app'); + assert.equal(response.data?.bundleId, 'com.example.app'); + assert.equal(response.data?.appPath, appPath); + } + assert.equal(session.actions.length, 1); + assert.equal(session.actions[0]?.command, 'reinstall'); +}); + +test('reinstall succeeds on active Android session with normalized appId', async () => { + const sessionStore = makeStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + ); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-success-android-')); + const appPath = path.join(tempRoot, 'Sample.apk'); + fs.writeFileSync(appPath, 'placeholder'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.app', appPath], + flags: {}, + }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore, + invoke, + reinstallOps: { + ios: async () => { + throw new Error('unexpected ios reinstall'); + }, + android: async (_device, app, pathToBinary) => { + assert.equal(app, 'com.example.app'); + assert.equal(pathToBinary, appPath); + return { package: 'com.example.app' }; + }, + }, + }); + + assert.ok(response); + assert.equal(response.ok, true); + if (response.ok) { + assert.equal(response.data?.platform, 'android'); + assert.equal(response.data?.appId, 'com.example.app'); + assert.equal(response.data?.package, 'com.example.app'); + assert.equal(response.data?.appPath, appPath); + } +}); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 35c972d4d..098a8e99b 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -14,6 +14,39 @@ import { pruneGroupNodes } from '../snapshot-processing.ts'; import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts'; import { inferFillText, uniqueStrings } from '../action-utils.ts'; +type ReinstallOps = { + ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>; + android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>; +}; + +function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean { + return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial); +} + +async function resolveCommandDevice(params: { + session: SessionState | undefined; + flags: DaemonRequest['flags'] | undefined; + ensureReadyFn: typeof ensureDeviceReady; + ensureReady?: boolean; +}): Promise { + const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {})); + if (params.ensureReady !== false) { + await params.ensureReadyFn(device); + } + return device; +} + +const defaultReinstallOps: ReinstallOps = { + ios: async (device, app, appPath) => { + const { reinstallIosApp } = await import('../../platforms/ios/index.ts'); + return await reinstallIosApp(device, app, appPath); + }, + android: async (device, app, appPath) => { + const { reinstallAndroidApp } = await import('../../platforms/android/index.ts'); + return await reinstallAndroidApp(device, app, appPath); + }, +}; + export async function handleSessionCommands(params: { req: DaemonRequest; sessionName: string; @@ -22,6 +55,7 @@ export async function handleSessionCommands(params: { invoke: (req: DaemonRequest) => Promise; dispatch?: typeof dispatchCommand; ensureReady?: typeof ensureDeviceReady; + reinstallOps?: ReinstallOps; }): Promise { const { req, @@ -31,6 +65,7 @@ export async function handleSessionCommands(params: { invoke, dispatch: dispatchOverride, ensureReady: ensureReadyOverride, + reinstallOps = defaultReinstallOps, } = params; const dispatch = dispatchOverride ?? dispatchCommand; const ensureReady = ensureReadyOverride ?? ensureDeviceReady; @@ -82,7 +117,7 @@ export async function handleSessionCommands(params: { if (command === 'apps') { const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; - if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) { + if (!session && !hasExplicitDeviceSelector(flags)) { return { ok: false, error: { @@ -91,8 +126,7 @@ export async function handleSessionCommands(params: { }, }; } - const device = session?.device ?? (await resolveTargetDevice(flags)); - await ensureReady(device); + const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true }); if (!isCommandSupportedOnDevice('apps', device)) { return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } }; } @@ -119,7 +153,7 @@ export async function handleSessionCommands(params: { if (command === 'boot') { const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; - if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) { + if (!session && !hasExplicitDeviceSelector(flags)) { return { ok: false, error: { @@ -148,8 +182,7 @@ export async function handleSessionCommands(params: { if (command === 'appstate') { const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; - const device = session?.device ?? (await resolveTargetDevice(flags)); - await ensureReady(device); + const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true }); if (device.platform === 'ios') { if (session?.appBundleId) { return { @@ -190,6 +223,62 @@ export async function handleSessionCommands(params: { }; } + if (command === 'reinstall') { + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + if (!session && !hasExplicitDeviceSelector(flags)) { + return { + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).', + }, + }; + } + const app = req.positionals?.[0]?.trim(); + const appPathInput = req.positionals?.[1]?.trim(); + if (!app || !appPathInput) { + return { + ok: false, + error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall ' }, + }; + } + const appPath = SessionStore.expandHome(appPathInput); + if (!fs.existsSync(appPath)) { + return { + ok: false, + error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` }, + }; + } + const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false }); + if (!isCommandSupportedOnDevice('reinstall', device)) { + return { + ok: false, + error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' }, + }; + } + let reinstallData: + | { platform: 'ios'; appId: string; bundleId: string } + | { platform: 'android'; appId: string; package: string }; + if (device.platform === 'ios') { + const iosResult = await reinstallOps.ios(device, app, appPath); + reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId }; + } else { + const androidResult = await reinstallOps.android(device, app, appPath); + reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package }; + } + const result = { app, appPath, ...reinstallData }; + if (session) { + sessionStore.recordAction(session, { + command, + positionals: req.positionals ?? [], + flags: req.flags ?? {}, + result, + }); + } + return { ok: true, data: result }; + } + if (command === 'open') { if (sessionStore.has(sessionName)) { const session = sessionStore.get(sessionName); @@ -232,7 +321,6 @@ export async function handleSessionCommands(params: { return { ok: true, data: { session: sessionName, appName, appBundleId } }; } const device = await resolveTargetDevice(req.flags ?? {}); - await ensureDeviceReady(device); const inUse = sessionStore.toArray().find((s) => s.device.id === device.id); if (inUse) { return { diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index 31cb73aa3..0e8bd1a9d 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -2,7 +2,7 @@ import { runCmd, whichCmd } from '../../utils/exec.ts'; import type { ExecResult } from '../../utils/exec.ts'; import { AppError, asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; -import { Deadline, retryWithPolicy, type RetryTelemetryEvent } from '../../utils/retry.ts'; +import { Deadline, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts'; import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; const EMULATOR_SERIAL_PREFIX = 'emulator-'; @@ -19,9 +19,13 @@ function isEmulatorSerial(serial: string): boolean { return serial.startsWith(EMULATOR_SERIAL_PREFIX); } -async function readAndroidBootProp(serial: string): Promise { +async function readAndroidBootProp( + serial: string, + timeoutMs = TIMEOUT_PROFILES.android_boot.operationMs, +): Promise { return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), { allowFailure: true, + timeoutMs, }); } @@ -30,6 +34,7 @@ async function resolveAndroidDeviceName(serial: string, rawModel: string): Promi if (!isEmulatorSerial(serial)) return modelName || serial; const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), { allowFailure: true, + timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs, }); const avdName = avd.stdout.trim(); if (avd.exitCode === 0 && avdName) { @@ -44,7 +49,9 @@ export async function listAndroidDevices(): Promise { throw new AppError('TOOL_MISSING', 'adb not found in PATH'); } - const result = await runCmd('adb', ['devices', '-l']); + const result = await runCmd('adb', ['devices', '-l'], { + timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs, + }); const lines = result.stdout.split('\n').map((l: string) => l.trim()); const entries = lines .filter((line) => line.length > 0 && !line.startsWith('List of devices')) @@ -99,7 +106,11 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro message: 'timeout', }); } - const result = await readAndroidBootProp(serial); + const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? timeoutBudget); + const result = await readAndroidBootProp( + serial, + Math.min(remainingMs, TIMEOUT_PROFILES.android_boot.operationMs), + ); lastBootResult = result; if (result.stdout.trim() === '1') return; throw new AppError('COMMAND_FAILED', 'Android device is still booting', { diff --git a/src/platforms/android/index.ts b/src/platforms/android/index.ts index 372f1a289..412057d83 100644 --- a/src/platforms/android/index.ts +++ b/src/platforms/android/index.ts @@ -272,6 +272,45 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise< await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value])); } +export async function uninstallAndroidApp( + device: DeviceInfo, + app: string, +): Promise<{ package: string }> { + const resolved = await resolveAndroidApp(device, app); + if (resolved.type === 'intent') { + throw new AppError('INVALID_ARGS', 'reinstall requires a package name, not an intent'); + } + const result = await runCmd('adb', adbArgs(device, ['uninstall', resolved.value]), { allowFailure: true }); + if (result.exitCode !== 0) { + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); + if (!output.includes('unknown package') && !output.includes('not installed')) { + throw new AppError('COMMAND_FAILED', `adb uninstall failed for ${resolved.value}`, { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + } + return { package: resolved.value }; +} + +export async function installAndroidApp(device: DeviceInfo, appPath: string): Promise { + await runCmd('adb', adbArgs(device, ['install', appPath])); +} + +export async function reinstallAndroidApp( + device: DeviceInfo, + app: string, + appPath: string, +): Promise<{ package: string }> { + if (!device.booted) { + await waitForAndroidBoot(device.id); + } + const { package: pkg } = await uninstallAndroidApp(device, app); + await installAndroidApp(device, appPath); + return { package: pkg }; +} + export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise { await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)])); } diff --git a/src/platforms/ios/index.ts b/src/platforms/ios/index.ts index 0688e6561..a43c9c8f4 100644 --- a/src/platforms/ios/index.ts +++ b/src/platforms/ios/index.ts @@ -95,6 +95,42 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise { + ensureSimulator(device, 'reinstall'); + const bundleId = await resolveIosApp(device, app); + await ensureBootedSimulator(device); + const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], { + allowFailure: true, + }); + if (result.exitCode !== 0) { + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); + if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) { + throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + } + return { bundleId }; +} + +export async function installIosApp(device: DeviceInfo, appPath: string): Promise { + ensureSimulator(device, 'reinstall'); + await ensureBootedSimulator(device); + await runCmd('xcrun', ['simctl', 'install', device.id, appPath]); +} + +export async function reinstallIosApp( + device: DeviceInfo, + app: string, + appPath: string, +): Promise<{ bundleId: string }> { + const { bundleId } = await uninstallIosApp(device, app); + await installIosApp(device, appPath); + return { bundleId }; +} + export async function screenshotIos(device: DeviceInfo, outPath: string): Promise { if (device.kind === 'simulator') { await ensureBootedSimulator(device); @@ -224,8 +260,17 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { let bootStatusResult: ExecResult | undefined; try { await retryWithPolicy( - async () => { - bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true }); + async ({ deadline: attemptDeadline }) => { + if (attemptDeadline?.isExpired()) { + throw new AppError('COMMAND_FAILED', 'iOS simulator boot deadline exceeded', { + timeoutMs: IOS_BOOT_TIMEOUT_MS, + }); + } + const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? IOS_BOOT_TIMEOUT_MS); + bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], { + allowFailure: true, + timeoutMs: remainingMs, + }); const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase(); const bootAlreadyDone = bootOutput.includes('already booted') || bootOutput.includes('current state: booted'); @@ -238,6 +283,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { } bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], { allowFailure: true, + timeoutMs: remainingMs, }); if (bootStatusResult.exitCode !== 0) { throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', { @@ -321,6 +367,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { async function getSimulatorState(udid: string): Promise { const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], { allowFailure: true, + timeoutMs: TIMEOUT_PROFILES.ios_boot.operationMs, }); if (result.exitCode !== 0) return null; try { diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 8c8cedd5c..74597d522 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -205,7 +205,10 @@ export async function stopIosRunnerSession(deviceId: string): Promise { } async function ensureBooted(udid: string): Promise { - await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], { allowFailure: true }); + await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], { + allowFailure: true, + timeoutMs: RUNNER_STARTUP_TIMEOUT_MS, + }); } async function ensureRunnerSession( diff --git a/src/utils/__tests__/exec.test.ts b/src/utils/__tests__/exec.test.ts new file mode 100644 index 000000000..482b8dcd8 --- /dev/null +++ b/src/utils/__tests__/exec.test.ts @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runCmd } from '../exec.ts'; + +test('runCmd enforces timeoutMs and rejects with COMMAND_FAILED', async () => { + await assert.rejects( + runCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], { timeoutMs: 100 }), + (error: unknown) => { + const err = error as { code?: string; message?: string; details?: Record }; + return err?.code === 'COMMAND_FAILED' && + typeof err?.message === 'string' && + err.message.includes('timed out') && + err.details?.timeoutMs === 100; + }, + ); +}); diff --git a/src/utils/args.ts b/src/utils/args.ts index 71f812ec6..cb1968c0d 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -176,6 +176,7 @@ Commands: boot Ensure target device/simulator is booted and ready open [app] Boot device/simulator; optionally launch app close [app] Close app or just end session + reinstall Uninstall + install app from binary path snapshot [-i] [-c] [-d ] [-s ] [--raw] [--backend ax|xctest] Capture accessibility tree -i Interactive elements only diff --git a/src/utils/exec.ts b/src/utils/exec.ts index f8935315f..f9938ecb8 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -14,6 +14,7 @@ export type ExecOptions = { allowFailure?: boolean; binaryStdout?: boolean; stdin?: string | Buffer; + timeoutMs?: number; }; export type ExecStreamOptions = ExecOptions & { @@ -41,6 +42,14 @@ export async function runCmd( let stdout = ''; let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined; let stderr = ''; + let didTimeout = false; + const timeoutMs = normalizeTimeoutMs(options.timeoutMs); + const timeoutHandle = timeoutMs + ? setTimeout(() => { + didTimeout = true; + child.kill('SIGKILL'); + }, timeoutMs) + : null; if (!options.binaryStdout) child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); @@ -66,6 +75,7 @@ export async function runCmd( }); child.on('error', (err) => { + if (timeoutHandle) clearTimeout(timeoutHandle); const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') { reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err)); @@ -75,7 +85,21 @@ export async function runCmd( }); child.on('close', (code) => { + if (timeoutHandle) clearTimeout(timeoutHandle); const exitCode = code ?? 1; + if (didTimeout && timeoutMs) { + reject( + new AppError('COMMAND_FAILED', `${cmd} timed out after ${timeoutMs}ms`, { + cmd, + args, + stdout, + stderr, + exitCode, + timeoutMs, + }), + ); + return; + } if (exitCode !== 0 && !options.allowFailure) { reject( new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, { @@ -110,10 +134,18 @@ export function runCmdSync(cmd: string, args: string[], options: ExecOptions = { stdio: ['pipe', 'pipe', 'pipe'], encoding: options.binaryStdout ? undefined : 'utf8', input: options.stdin, + timeout: normalizeTimeoutMs(options.timeoutMs), }); if (result.error) { const code = (result.error as NodeJS.ErrnoException).code; + if (code === 'ETIMEDOUT') { + throw new AppError('COMMAND_FAILED', `${cmd} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`, { + cmd, + args, + timeoutMs: normalizeTimeoutMs(options.timeoutMs), + }, result.error); + } if (code === 'ENOENT') { throw new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, result.error); } @@ -298,3 +330,10 @@ function resolveWhichArgs(cmd: string): { shell: string; args: string[] } { } return { shell: 'bash', args: ['-lc', `command -v ${cmd}`] }; } + +function normalizeTimeoutMs(value: number | undefined): number | undefined { + if (!Number.isFinite(value)) return undefined; + const timeout = Math.floor(value as number); + if (timeout <= 0) return undefined; + return timeout; +} diff --git a/test/integration/smoke-cli.test.ts b/test/integration/smoke-cli.test.ts index 5786e1c0b..7a068b442 100644 --- a/test/integration/smoke-cli.test.ts +++ b/test/integration/smoke-cli.test.ts @@ -15,6 +15,7 @@ test('cli --help returns usage', () => { const result = runCli(['--help']); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /agent-device/i); + assert.match(result.stdout, /reinstall /i); }); test('cli --version prints semver and exits 0', () => { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d4fb1512a..0ada45449 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -9,6 +9,9 @@ This page summarizes the primary command groups. ## Navigation ```bash +agent-device boot +agent-device boot --platform ios +agent-device boot --platform android agent-device open [app] agent-device close [app] agent-device back @@ -16,6 +19,11 @@ agent-device home agent-device app-switcher ``` +- `boot` ensures the selected target is ready without launching an app. +- `boot` requires either an active session or an explicit device selector. +- `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available. +- `open [app]` already boots/activates the selected target when needed. + ## Snapshot and inspect ```bash @@ -61,6 +69,17 @@ agent-device replay -u ./session.ad # Update selector drift and rewrite .ad sc See [Replay & E2E (Experimental)](/docs/replay-e2e) for recording and CI workflow details. +## App reinstall (fresh state) + +```bash +agent-device reinstall com.example.app ./build/app.apk --platform android +agent-device reinstall com.example.app ./build/MyApp.app --platform ios +``` + +- `reinstall ` uninstalls and installs in one command. +- Supports Android devices/emulators and iOS simulators in v1. +- Useful for login/logout reset flows and deterministic test setup. + ## Settings helpers ```bash diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index 8d9bcc1cc..9700be9c1 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -23,6 +23,12 @@ agent-device click @e2 agent-device snapshot -i ``` +boot simulator if there's none available: + +```bash +agent-device boot --platform ios # or android +``` + ## Common commands ```bash @@ -35,6 +41,8 @@ agent-device screenshot page.png # Save to specific path agent-device close ``` +If `open` fails because no booted simulator/emulator is available, run `boot --platform ios|android` and retry. + ## Semantic discovery Use `find` for human-readable targeting without refs: