From b704f2d5230b2f07565a9b49553345d07e1a7ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 17 Feb 2026 18:35:24 +0100 Subject: [PATCH 1/3] fix: support reinstall on physical iOS devices --- README.md | 4 +- src/core/__tests__/capabilities.test.ts | 8 +- src/core/capabilities.ts | 2 +- .../__tests__/session-reinstall.test.ts | 22 ++- src/platforms/ios/__tests__/index.test.ts | 165 +++++++++++++++++- src/platforms/ios/apps.ts | 40 ++++- 6 files changed, 228 insertions(+), 13 deletions(-) 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..24c536d4d 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -3,7 +3,7 @@ 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'; @@ -130,6 +130,169 @@ test('openIosApp custom scheme on iOS device uses active app context', async () } }); +test('reinstallIosApp on iOS physical device uses devicectl uninstall + install', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-reinstall-device-test-')); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const argsLogPath = path.join(tmpDir, 'args.log'); + await fs.writeFile( + xcrunPath, + [ + '#!/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', + '', + ].join('\n'), + '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; + + const device: DeviceInfo = { + platform: 'ios', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }; + + try { + 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, + ]); + } 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('reinstallIosApp on iOS physical device proceeds when uninstall reports app not installed', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-reinstall-device-missing-app-test-')); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const argsLogPath = path.join(tmpDir, 'args.log'); + await fs.writeFile( + xcrunPath, + [ + '#!/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', + '', + ].join('\n'), + '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; + + const device: DeviceInfo = { + platform: 'ios', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }; + + try { + 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); + } 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 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..71597422e 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -5,7 +5,13 @@ 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_DEVICECTL_DEFAULT_HINT, + listIosDeviceApps, + resolveIosDevicectlHint, + runIosDevicectl, + type IosAppInfo, +} from './devicectl.ts'; import { ensureBootedSimulator, ensureSimulator, getSimulatorState } from './simulator.ts'; const ALIASES: Record = { @@ -125,8 +131,29 @@ 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 }); + if (result.exitCode !== 0) { + const stdout = String(result.stdout ?? ''); + const stderr = String(result.stderr ?? ''); + const output = `${stdout}\n${stderr}`.toLowerCase(); + if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) { + 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], { @@ -147,7 +174,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]); } From 5234adbb90eb6f1b9875590ead3975ffa818a58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 18 Feb 2026 10:20:03 +0100 Subject: [PATCH 2/3] fix: add devicectl timeout for iOS uninstall --- src/platforms/ios/apps.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index 71597422e..4aaf2aff1 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -4,7 +4,7 @@ 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 { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; import { IOS_DEVICECTL_DEFAULT_HINT, listIosDeviceApps, @@ -134,7 +134,10 @@ export async function uninstallIosApp(device: DeviceInfo, app: string): Promise< 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 }); + 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 ?? ''); From 6a3c5c622260aedb2eb5849cec418ac7631e1f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 18 Feb 2026 10:43:42 +0100 Subject: [PATCH 3/3] chore: simplify iOS reinstall error handling and tests --- src/platforms/ios/__tests__/index.test.ts | 205 ++++++++++------------ src/platforms/ios/apps.ts | 8 +- 2 files changed, 98 insertions(+), 115 deletions(-) diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 24c536d4d..9ec7577b4 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -7,6 +7,43 @@ import { listIosApps, openIosApp, parseIosDeviceAppsPayload, reinstallIosApp, re 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', @@ -131,49 +168,27 @@ test('openIosApp custom scheme on iOS device uses active app context', async () }); test('reinstallIosApp on iOS physical device uses devicectl uninstall + install', async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-reinstall-device-test-')); - const xcrunPath = path.join(tmpDir, 'xcrun'); - const argsLogPath = path.join(tmpDir, 'args.log'); - await fs.writeFile( - xcrunPath, - [ - '#!/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', - '', - ].join('\n'), - '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; - - const device: DeviceInfo = { - platform: 'ios', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }; - - try { + 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); @@ -207,70 +222,41 @@ test('reinstallIosApp on iOS physical device uses devicectl uninstall + install' 'ios-device-1', appPath, ]); - } 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('reinstallIosApp on iOS physical device proceeds when uninstall reports app not installed', async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-reinstall-device-missing-app-test-')); - const xcrunPath = path.join(tmpDir, 'xcrun'); - const argsLogPath = path.join(tmpDir, 'args.log'); - await fs.writeFile( - xcrunPath, - [ - '#!/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', - '', - ].join('\n'), - '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; - - const device: DeviceInfo = { - platform: 'ios', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }; - - try { + 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); @@ -282,15 +268,8 @@ test('reinstallIosApp on iOS physical device proceeds when uninstall reports app .filter(Boolean); assert.equal(args.includes('uninstall'), true); assert.equal(args.includes('install'), true); - } 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 with app and URL on iOS device launches app bundle with payload URL', async () => { diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index 4aaf2aff1..bc18323fa 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -18,6 +18,10 @@ 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; @@ -142,7 +146,7 @@ export async function uninstallIosApp(device: DeviceInfo, app: string): Promise< const stdout = String(result.stdout ?? ''); const stderr = String(result.stderr ?? ''); const output = `${stdout}\n${stderr}`.toLowerCase(); - if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) { + if (!isMissingAppErrorOutput(output)) { throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, { cmd: 'xcrun', args, @@ -164,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,