Skip to content

Commit c1c50af

Browse files
authored
fix: support reinstall on physical iOS devices (#67)
* fix: support reinstall on physical iOS devices * fix: add devicectl timeout for iOS uninstall * chore: simplify iOS reinstall error handling and tests
1 parent bceda35 commit c1c50af

6 files changed

Lines changed: 216 additions & 15 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Navigation helpers:
147147
- `boot --platform ios|android` ensures the target is ready without launching an app.
148148
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
149149
- `open [app|url] [url]` already boots/activates the selected target when needed.
150-
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator).
150+
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator/device).
151151
- `reinstall` accepts package/bundle id style app names and supports `~` in paths.
152152

153153
Deep links:
@@ -242,7 +242,7 @@ Boot diagnostics:
242242

243243
## iOS notes
244244
- Core runner commands: `snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`.
245-
- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `settings`.
245+
- Simulator-only commands: `alert`, `pinch`, `record`, `settings`.
246246
- 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`.
247247

248248
## Testing

src/core/__tests__/capabilities.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,19 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
3333
});
3434

3535
test('simulator-only iOS commands with Android support reject iOS devices', () => {
36-
for (const cmd of ['reinstall', 'record', 'settings', 'swipe']) {
36+
for (const cmd of ['record', 'settings', 'swipe']) {
3737
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
3838
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
3939
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
4040
}
4141
});
4242

43+
test('reinstall supports iOS simulator, iOS device, and Android', () => {
44+
assert.equal(isCommandSupportedOnDevice('reinstall', iosSimulator), true, 'reinstall on iOS sim');
45+
assert.equal(isCommandSupportedOnDevice('reinstall', iosDevice), true, 'reinstall on iOS device');
46+
assert.equal(isCommandSupportedOnDevice('reinstall', androidDevice), true, 'reinstall on Android');
47+
});
48+
4349
test('core commands support iOS simulator, iOS device, and Android', () => {
4450
for (const cmd of [
4551
'app-switcher',

src/core/capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
3030
home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3131
'long-press': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3232
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
33-
reinstall: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
33+
reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3434
press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3535
record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3636
screenshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },

src/daemon/handlers/__tests__/session-reinstall.test.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ test('reinstall validates required args before device operations', async () => {
8181
}
8282
});
8383

84-
test('reinstall reports unsupported operation on iOS physical devices', async () => {
84+
test('reinstall succeeds on active iOS physical device session', async () => {
8585
const sessionStore = makeStore();
8686
sessionStore.set(
8787
'default',
@@ -109,12 +109,24 @@ test('reinstall reports unsupported operation on iOS physical devices', async ()
109109
logPath: '/tmp/daemon.log',
110110
sessionStore,
111111
invoke,
112+
reinstallOps: {
113+
ios: async (_device, app, pathToBinary) => {
114+
assert.equal(app, 'com.example.app');
115+
assert.equal(pathToBinary, appPath);
116+
return { bundleId: 'com.example.app' };
117+
},
118+
android: async () => {
119+
throw new Error('unexpected android reinstall');
120+
},
121+
},
112122
});
113123
assert.ok(response);
114-
assert.equal(response.ok, false);
115-
if (!response.ok) {
116-
assert.equal(response.error.code, 'UNSUPPORTED_OPERATION');
117-
assert.match(response.error.message, /reinstall is not supported/i);
124+
assert.equal(response.ok, true);
125+
if (response.ok) {
126+
assert.equal(response.data?.platform, 'ios');
127+
assert.equal(response.data?.appId, 'com.example.app');
128+
assert.equal(response.data?.bundleId, 'com.example.app');
129+
assert.equal(response.data?.appPath, appPath);
118130
}
119131
});
120132

src/platforms/ios/__tests__/index.test.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,47 @@ import assert from 'node:assert/strict';
33
import { promises as fs } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6-
import { listIosApps, openIosApp, parseIosDeviceAppsPayload, resolveIosApp } from '../index.ts';
6+
import { listIosApps, openIosApp, parseIosDeviceAppsPayload, reinstallIosApp, resolveIosApp } from '../index.ts';
77
import type { DeviceInfo } from '../../../utils/device.ts';
88
import { AppError } from '../../../utils/errors.ts';
99

10+
const IOS_TEST_DEVICE: DeviceInfo = {
11+
platform: 'ios',
12+
id: 'ios-device-1',
13+
name: 'iPhone Device',
14+
kind: 'device',
15+
booted: true,
16+
};
17+
18+
async function withMockedXcrun(
19+
tempPrefix: string,
20+
script: string,
21+
run: (ctx: { tmpDir: string; argsLogPath: string; device: DeviceInfo }) => Promise<void>,
22+
): Promise<void> {
23+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
24+
const xcrunPath = path.join(tmpDir, 'xcrun');
25+
const argsLogPath = path.join(tmpDir, 'args.log');
26+
await fs.writeFile(xcrunPath, script, 'utf8');
27+
await fs.chmod(xcrunPath, 0o755);
28+
29+
const previousPath = process.env.PATH;
30+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
31+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
32+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
33+
34+
try {
35+
await run({ tmpDir, argsLogPath, device: IOS_TEST_DEVICE });
36+
} finally {
37+
process.env.PATH = previousPath;
38+
if (previousArgsFile === undefined) {
39+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
40+
} else {
41+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
42+
}
43+
await fs.rm(tmpDir, { recursive: true, force: true });
44+
}
45+
}
46+
1047
test('openIosApp custom scheme deep links on iOS devices require app bundle context', async () => {
1148
const device: DeviceInfo = {
1249
platform: 'ios',
@@ -130,6 +167,111 @@ test('openIosApp custom scheme on iOS device uses active app context', async ()
130167
}
131168
});
132169

170+
test('reinstallIosApp on iOS physical device uses devicectl uninstall + install', async () => {
171+
await withMockedXcrun(
172+
'agent-device-ios-reinstall-device-test-',
173+
`#!/bin/sh
174+
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
175+
if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "info" ] && [ "$4" = "apps" ]; then
176+
out=""
177+
while [ "$#" -gt 0 ]; do
178+
if [ "$1" = "--json-output" ]; then
179+
out="$2"
180+
shift 2
181+
continue
182+
fi
183+
shift
184+
done
185+
cat > "$out" <<'JSON'
186+
{"result":{"apps":[{"bundleIdentifier":"com.example.demo","name":"Demo"}]}}
187+
JSON
188+
fi
189+
exit 0
190+
`,
191+
async ({ tmpDir, argsLogPath, device }) => {
192+
const appPath = path.join(tmpDir, 'Sample.app');
193+
await fs.writeFile(appPath, 'placeholder', 'utf8');
194+
const result = await reinstallIosApp(device, 'Demo', appPath);
195+
assert.equal(result.bundleId, 'com.example.demo');
196+
197+
const args = (await fs.readFile(argsLogPath, 'utf8'))
198+
.trim()
199+
.split('\n')
200+
.filter(Boolean);
201+
202+
const uninstallIdx = args.indexOf('uninstall');
203+
const installIdx = args.indexOf('install');
204+
assert.notEqual(uninstallIdx, -1);
205+
assert.notEqual(installIdx, -1);
206+
assert.equal(uninstallIdx < installIdx, true, 'reinstall should uninstall before install');
207+
assert.deepEqual(args.slice(uninstallIdx - 2, uninstallIdx + 5), [
208+
'devicectl',
209+
'device',
210+
'uninstall',
211+
'app',
212+
'--device',
213+
'ios-device-1',
214+
'com.example.demo',
215+
]);
216+
assert.deepEqual(args.slice(installIdx - 2, installIdx + 5), [
217+
'devicectl',
218+
'device',
219+
'install',
220+
'app',
221+
'--device',
222+
'ios-device-1',
223+
appPath,
224+
]);
225+
},
226+
);
227+
});
228+
229+
test('reinstallIosApp on iOS physical device proceeds when uninstall reports app not installed', async () => {
230+
await withMockedXcrun(
231+
'agent-device-ios-reinstall-device-missing-app-test-',
232+
`#!/bin/sh
233+
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
234+
if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "info" ] && [ "$4" = "apps" ]; then
235+
out=""
236+
while [ "$#" -gt 0 ]; do
237+
if [ "$1" = "--json-output" ]; then
238+
out="$2"
239+
shift 2
240+
continue
241+
fi
242+
shift
243+
done
244+
cat > "$out" <<'JSON'
245+
{"result":{"apps":[{"bundleIdentifier":"com.example.demo","name":"Demo"}]}}
246+
JSON
247+
exit 0
248+
fi
249+
if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "uninstall" ] && [ "$4" = "app" ]; then
250+
echo "app not installed" >&2
251+
exit 1
252+
fi
253+
if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "install" ] && [ "$4" = "app" ]; then
254+
exit 0
255+
fi
256+
echo "unexpected xcrun args: $@" >&2
257+
exit 1
258+
`,
259+
async ({ tmpDir, argsLogPath, device }) => {
260+
const appPath = path.join(tmpDir, 'Sample.app');
261+
await fs.writeFile(appPath, 'placeholder', 'utf8');
262+
const result = await reinstallIosApp(device, 'Demo', appPath);
263+
assert.equal(result.bundleId, 'com.example.demo');
264+
265+
const args = (await fs.readFile(argsLogPath, 'utf8'))
266+
.trim()
267+
.split('\n')
268+
.filter(Boolean);
269+
assert.equal(args.includes('uninstall'), true);
270+
assert.equal(args.includes('install'), true);
271+
},
272+
);
273+
});
274+
133275
test('openIosApp with app and URL on iOS device launches app bundle with payload URL', async () => {
134276
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-open-app-url-test-'));
135277
const xcrunPath = path.join(tmpDir, 'xcrun');

src/platforms/ios/apps.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@ import { runCmd } from '../../utils/exec.ts';
44
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
55
import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
66

7-
import { IOS_APP_LAUNCH_TIMEOUT_MS } from './config.ts';
8-
import { listIosDeviceApps, runIosDevicectl, type IosAppInfo } from './devicectl.ts';
7+
import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts';
8+
import {
9+
IOS_DEVICECTL_DEFAULT_HINT,
10+
listIosDeviceApps,
11+
resolveIosDevicectlHint,
12+
runIosDevicectl,
13+
type IosAppInfo,
14+
} from './devicectl.ts';
915
import { ensureBootedSimulator, ensureSimulator, getSimulatorState } from './simulator.ts';
1016

1117
const ALIASES: Record<string, string> = {
1218
settings: 'com.apple.Preferences',
1319
};
1420

21+
function isMissingAppErrorOutput(output: string): boolean {
22+
return output.includes('not installed') || output.includes('not found') || output.includes('no such file');
23+
}
24+
1525
export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
1626
const trimmed = app.trim();
1727
if (trimmed.includes('.')) return trimmed;
@@ -125,16 +135,40 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise<void
125135
}
126136

127137
export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
128-
ensureSimulator(device, 'reinstall');
129138
const bundleId = await resolveIosApp(device, app);
139+
if (device.kind !== 'simulator') {
140+
const args = ['devicectl', 'device', 'uninstall', 'app', '--device', device.id, bundleId];
141+
const result = await runCmd('xcrun', args, {
142+
allowFailure: true,
143+
timeoutMs: IOS_DEVICECTL_TIMEOUT_MS,
144+
});
145+
if (result.exitCode !== 0) {
146+
const stdout = String(result.stdout ?? '');
147+
const stderr = String(result.stderr ?? '');
148+
const output = `${stdout}\n${stderr}`.toLowerCase();
149+
if (!isMissingAppErrorOutput(output)) {
150+
throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, {
151+
cmd: 'xcrun',
152+
args,
153+
exitCode: result.exitCode,
154+
stdout,
155+
stderr,
156+
deviceId: device.id,
157+
hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT,
158+
});
159+
}
160+
}
161+
return { bundleId };
162+
}
163+
130164
await ensureBootedSimulator(device);
131165

132166
const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], {
133167
allowFailure: true,
134168
});
135169
if (result.exitCode !== 0) {
136170
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
137-
if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) {
171+
if (!isMissingAppErrorOutput(output)) {
138172
throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
139173
stdout: result.stdout,
140174
stderr: result.stderr,
@@ -147,7 +181,14 @@ export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<
147181
}
148182

149183
export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
150-
ensureSimulator(device, 'reinstall');
184+
if (device.kind !== 'simulator') {
185+
await runIosDevicectl(['device', 'install', 'app', '--device', device.id, appPath], {
186+
action: 'install iOS app',
187+
deviceId: device.id,
188+
});
189+
return;
190+
}
191+
151192
await ensureBootedSimulator(device);
152193
await runCmd('xcrun', ['simctl', 'install', device.id, appPath]);
153194
}

0 commit comments

Comments
 (0)