diff --git a/README.md b/README.md index 67d52bdb3..366e0b5bc 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ 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|url] [url]` already boots/activates the selected target when needed. -- `reinstall ` uninstalls and installs the app binary in one command (Android + iOS simulator). +- `reinstall ` uninstalls and installs the app binary in one command (Android + iOS simulator/device). - `reinstall` accepts package/bundle id style app names and supports `~` in paths. Deep links: @@ -242,7 +242,7 @@ Boot diagnostics: ## iOS notes - Core runner commands: `snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`. -- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `settings`. +- Simulator-only commands: `alert`, `pinch`, `record`, `settings`. - iOS device runs require valid signing/provisioning (Automatic Signing recommended). Optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`. ## Testing diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index b4cfbea91..b0f8242a3 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -33,13 +33,19 @@ test('iOS simulator-only commands reject iOS devices and Android', () => { }); test('simulator-only iOS commands with Android support reject iOS devices', () => { - for (const cmd of ['reinstall', 'record', 'settings', 'swipe']) { + for (const cmd of ['record', 'settings', 'swipe']) { assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`); assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`); assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`); } }); +test('reinstall supports iOS simulator, iOS device, and Android', () => { + assert.equal(isCommandSupportedOnDevice('reinstall', iosSimulator), true, 'reinstall on iOS sim'); + assert.equal(isCommandSupportedOnDevice('reinstall', iosDevice), true, 'reinstall on iOS device'); + assert.equal(isCommandSupportedOnDevice('reinstall', androidDevice), true, 'reinstall on Android'); +}); + test('core commands support iOS simulator, iOS device, and Android', () => { for (const cmd of [ 'app-switcher', diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 643d7fe76..b41d45632 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -30,7 +30,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, 'long-press': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, - reinstall: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, + reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } }, screenshot: { ios: { simulator: true, device: 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 index 5fe348e51..b1f72057b 100644 --- a/src/daemon/handlers/__tests__/session-reinstall.test.ts +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -81,7 +81,7 @@ test('reinstall validates required args before device operations', async () => { } }); -test('reinstall reports unsupported operation on iOS physical devices', async () => { +test('reinstall succeeds on active iOS physical device session', async () => { const sessionStore = makeStore(); sessionStore.set( 'default', @@ -109,12 +109,24 @@ test('reinstall reports unsupported operation on iOS physical devices', async () 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, false); - if (!response.ok) { - assert.equal(response.error.code, 'UNSUPPORTED_OPERATION'); - assert.match(response.error.message, /reinstall is not supported/i); + 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); } }); diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 6723083a9..9ec7577b4 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -3,10 +3,47 @@ import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { listIosApps, openIosApp, parseIosDeviceAppsPayload, resolveIosApp } from '../index.ts'; +import { listIosApps, openIosApp, parseIosDeviceAppsPayload, reinstallIosApp, resolveIosApp } from '../index.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; import { AppError } from '../../../utils/errors.ts'; +const IOS_TEST_DEVICE: DeviceInfo = { + platform: 'ios', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, +}; + +async function withMockedXcrun( + tempPrefix: string, + script: string, + run: (ctx: { tmpDir: string; argsLogPath: string; device: DeviceInfo }) => Promise, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const argsLogPath = path.join(tmpDir, 'args.log'); + await fs.writeFile(xcrunPath, script, 'utf8'); + await fs.chmod(xcrunPath, 0o755); + + const previousPath = process.env.PATH; + const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; + + try { + await run({ tmpDir, argsLogPath, device: IOS_TEST_DEVICE }); + } finally { + process.env.PATH = previousPath; + if (previousArgsFile === undefined) { + delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; + } else { + process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + test('openIosApp custom scheme deep links on iOS devices require app bundle context', async () => { const device: DeviceInfo = { platform: 'ios', @@ -130,6 +167,111 @@ test('openIosApp custom scheme on iOS device uses active app context', async () } }); +test('reinstallIosApp on iOS physical device uses devicectl uninstall + install', async () => { + await withMockedXcrun( + 'agent-device-ios-reinstall-device-test-', + `#!/bin/sh +printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "info" ] && [ "$4" = "apps" ]; then + out="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--json-output" ]; then + out="$2" + shift 2 + continue + fi + shift + done + cat > "$out" <<'JSON' +{"result":{"apps":[{"bundleIdentifier":"com.example.demo","name":"Demo"}]}} +JSON +fi +exit 0 +`, + async ({ tmpDir, argsLogPath, device }) => { + const appPath = path.join(tmpDir, 'Sample.app'); + await fs.writeFile(appPath, 'placeholder', 'utf8'); + const result = await reinstallIosApp(device, 'Demo', appPath); + assert.equal(result.bundleId, 'com.example.demo'); + + const args = (await fs.readFile(argsLogPath, 'utf8')) + .trim() + .split('\n') + .filter(Boolean); + + const uninstallIdx = args.indexOf('uninstall'); + const installIdx = args.indexOf('install'); + assert.notEqual(uninstallIdx, -1); + assert.notEqual(installIdx, -1); + assert.equal(uninstallIdx < installIdx, true, 'reinstall should uninstall before install'); + assert.deepEqual(args.slice(uninstallIdx - 2, uninstallIdx + 5), [ + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + 'ios-device-1', + 'com.example.demo', + ]); + assert.deepEqual(args.slice(installIdx - 2, installIdx + 5), [ + 'devicectl', + 'device', + 'install', + 'app', + '--device', + 'ios-device-1', + appPath, + ]); + }, + ); +}); + +test('reinstallIosApp on iOS physical device proceeds when uninstall reports app not installed', async () => { + await withMockedXcrun( + 'agent-device-ios-reinstall-device-missing-app-test-', + `#!/bin/sh +printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE" +if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "info" ] && [ "$4" = "apps" ]; then + out="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--json-output" ]; then + out="$2" + shift 2 + continue + fi + shift + done + cat > "$out" <<'JSON' +{"result":{"apps":[{"bundleIdentifier":"com.example.demo","name":"Demo"}]}} +JSON + exit 0 +fi +if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "uninstall" ] && [ "$4" = "app" ]; then + echo "app not installed" >&2 + exit 1 +fi +if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "install" ] && [ "$4" = "app" ]; then + exit 0 +fi +echo "unexpected xcrun args: $@" >&2 +exit 1 +`, + async ({ tmpDir, argsLogPath, device }) => { + const appPath = path.join(tmpDir, 'Sample.app'); + await fs.writeFile(appPath, 'placeholder', 'utf8'); + const result = await reinstallIosApp(device, 'Demo', appPath); + assert.equal(result.bundleId, 'com.example.demo'); + + const args = (await fs.readFile(argsLogPath, 'utf8')) + .trim() + .split('\n') + .filter(Boolean); + assert.equal(args.includes('uninstall'), true); + assert.equal(args.includes('install'), true); + }, + ); +}); + test('openIosApp with app and URL on iOS device launches app bundle with payload URL', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-open-app-url-test-')); const xcrunPath = path.join(tmpDir, 'xcrun'); diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index 4fd4461de..bc18323fa 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -4,14 +4,24 @@ import { runCmd } from '../../utils/exec.ts'; import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts'; -import { IOS_APP_LAUNCH_TIMEOUT_MS } from './config.ts'; -import { listIosDeviceApps, runIosDevicectl, type IosAppInfo } from './devicectl.ts'; +import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; +import { + IOS_DEVICECTL_DEFAULT_HINT, + listIosDeviceApps, + resolveIosDevicectlHint, + runIosDevicectl, + type IosAppInfo, +} from './devicectl.ts'; import { ensureBootedSimulator, ensureSimulator, getSimulatorState } from './simulator.ts'; const ALIASES: Record = { settings: 'com.apple.Preferences', }; +function isMissingAppErrorOutput(output: string): boolean { + return output.includes('not installed') || output.includes('not found') || output.includes('no such file'); +} + export async function resolveIosApp(device: DeviceInfo, app: string): Promise { const trimmed = app.trim(); if (trimmed.includes('.')) return trimmed; @@ -125,8 +135,32 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise { - ensureSimulator(device, 'reinstall'); const bundleId = await resolveIosApp(device, app); + if (device.kind !== 'simulator') { + const args = ['devicectl', 'device', 'uninstall', 'app', '--device', device.id, bundleId]; + const result = await runCmd('xcrun', args, { + allowFailure: true, + timeoutMs: IOS_DEVICECTL_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + const stdout = String(result.stdout ?? ''); + const stderr = String(result.stderr ?? ''); + const output = `${stdout}\n${stderr}`.toLowerCase(); + if (!isMissingAppErrorOutput(output)) { + throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, { + cmd: 'xcrun', + args, + exitCode: result.exitCode, + stdout, + stderr, + deviceId: device.id, + hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + }); + } + } + return { bundleId }; + } + await ensureBootedSimulator(device); const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], { @@ -134,7 +168,7 @@ export async function uninstallIosApp(device: DeviceInfo, app: string): Promise< }); 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')) { + if (!isMissingAppErrorOutput(output)) { throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, { stdout: result.stdout, stderr: result.stderr, @@ -147,7 +181,14 @@ export async function uninstallIosApp(device: DeviceInfo, app: string): Promise< } export async function installIosApp(device: DeviceInfo, appPath: string): Promise { - ensureSimulator(device, 'reinstall'); + if (device.kind !== 'simulator') { + await runIosDevicectl(['device', 'install', 'app', '--device', device.id, appPath], { + action: 'install iOS app', + deviceId: device.id, + }); + return; + } + await ensureBootedSimulator(device); await runCmd('xcrun', ['simctl', 'install', device.id, appPath]); }