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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator).
- `reinstall <app> <path>` 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:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
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 } },
Expand Down
22 changes: 17 additions & 5 deletions src/daemon/handlers/__tests__/session-reinstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
}
});

Expand Down
144 changes: 143 additions & 1 deletion src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
): Promise<void> {
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',
Expand Down Expand Up @@ -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');
Expand Down
51 changes: 46 additions & 5 deletions src/platforms/ios/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string> {
const trimmed = app.trim();
if (trimmed.includes('.')) return trimmed;
Expand Down Expand Up @@ -125,16 +135,40 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise<void
}

export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
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], {
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')) {
if (!isMissingAppErrorOutput(output)) {
throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
stdout: result.stdout,
stderr: result.stderr,
Expand All @@ -147,7 +181,14 @@ export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<
}

export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
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]);
}
Expand Down
Loading