Skip to content

fix: enable iOS real device support via devicectl and iproxy#3100

Open
manuelbieh wants to merge 1 commit intomobile-dev-inc:mainfrom
manuelbieh:fix/ios-real-device-support
Open

fix: enable iOS real device support via devicectl and iproxy#3100
manuelbieh wants to merge 1 commit intomobile-dev-inc:mainfrom
manuelbieh:fix/ios-real-device-support

Conversation

@manuelbieh
Copy link
Copy Markdown

Summary

Physical iOS device testing was broken due to three issues. This PR fixes them so maestro test --device <UDID> --apple-team-id <TEAM> works on connected iPhones.

1. MaestroDriverLib missing from CLI jar

The build.gradle.kts jar packaging only included maestro-driver-ios/**, maestro-driver-iosUITests/**, and maestro-driver-ios.xcodeproj/**. The MaestroDriverLib directory — which contains a framework target and Info.plist referenced by the Xcode project — was excluded. This caused xcodebuild build-for-testing to fail:

error: Build input files cannot be found: '.../MaestroDriverLib/Info.plist',
'.../MaestroDriverLib/Sources/MaestroDriverLib/MaestroDriverLib.swift' [...]

Fix: Added MaestroDriverLib/** to both the jar task and createTestResources task includes.

2. No USB port forwarding for real devices

The XCTest HTTP server runs on 127.0.0.1:PORT on the device's loopback. For simulators this works because they share the Mac's network. For physical devices, the port is unreachable without forwarding.

Fix: LocalXCTestInstaller now auto-starts iproxy (from libimobiledevice) when deviceType == REAL, forwarding the test runner port over USB. The process is cleaned up on close().

3. DeviceControlIOSDevice was entirely stubbed

Every method was TODO("Not yet implemented"), causing NotImplementedError on launchApp. The UI automation methods (tap, hierarchy, screenshot) are handled by the XCTest HTTP server and don't go through this class, but device management methods do.

Fix: Implemented the critical methods using xcrun devicectl:

  • launch()devicectl device process launch --terminate-existing
  • stop()devicectl device process terminate
  • open() — no-op (device is already running)
  • clearAppState() — best-effort terminate
  • clearKeychain(), isShutdown() — safe no-ops with logging
  • openLink() — launches Safari via devicectl

Requirements

  • libimobiledevice must be installed for iproxy (brew install libimobiledevice)
  • Apple Team ID passed via --apple-team-id

Test environment

  • iPhone 16 (iOS 26.3.1)
  • Xcode 26.4
  • macOS 26.2 (Apple Silicon)
  • Maestro 2.3.0 (built from source with this patch)

Verified: app launches, XCTest driver connects, assertions and taps execute successfully on a physical device.

Three issues prevented Maestro from working on physical iOS devices:

1. **MaestroDriverLib missing from CLI jar**: The `build.gradle.kts` jar
   task only included `maestro-driver-ios/**`, `maestro-driver-iosUITests/**`,
   and `maestro-driver-ios.xcodeproj/**` from the xctest-runner source. The
   `MaestroDriverLib` directory (which contains a framework target referenced
   by the Xcode project) was excluded, causing `xcodebuild build-for-testing`
   to fail with "Build input file cannot be found: MaestroDriverLib/Info.plist"
   and missing Swift source files.

2. **No USB port forwarding for real devices**: The XCTest HTTP server runs
   on the device's loopback (127.0.0.1:PORT), which is not reachable from
   the Mac for physical devices (unlike simulators which share localhost).
   Added automatic `iproxy` startup in `LocalXCTestInstaller` when the
   device type is REAL, forwarding the test runner port over USB. The
   iproxy process is cleaned up on close.

3. **DeviceControlIOSDevice methods were all TODO stubs**: Implemented the
   critical device management methods needed for test execution:
   - `open()`: no-op (device is already running)
   - `launch()`: uses `xcrun devicectl device process launch`
   - `stop()`: uses `xcrun devicectl device process terminate`
   - `clearAppState()`: best-effort terminate (full clear requires reinstall)
   - `clearKeychain()`: no-op with log (not supported on real devices)
   - `openLink()`: launches Safari with URL via devicectl
   - `isShutdown()`: returns false
   - `startScreenRecording()`: returns error (not yet supported)
   - `setLocation()`: returns error (not supported on real devices)

   Methods handled by the XCTest HTTP server (tap, viewHierarchy,
   takeScreenshot, etc.) are left as TODO since they are never called
   through this code path — the XCTestDriverClient handles them via HTTP.

Requires libimobiledevice (`brew install libimobiledevice`) for iproxy.

Tested on iPhone 16 (iOS 26.3.1) with Xcode 26.4 and macOS 26.2.
Copilot AI review requested due to automatic review settings March 31, 2026 15:52
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to restore physical iOS device testing support by packaging missing iOS driver resources into the CLI jar, adding USB port-forwarding for the XCTest HTTP server, and implementing real-device app management via xcrun devicectl.

Changes:

  • Package MaestroDriverLib/** into the CLI jar and test resources so the Xcode project can build.
  • Start an iproxy process on real devices to forward the XCTest HTTP server port over USB.
  • Implement core real-device app management in DeviceControlIOSDevice using xcrun devicectl.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt Adds devicectl-based implementations for launch/stop/openLink and introduces real-device “no-op/unsupported” behaviors.
maestro-ios-driver/src/main/kotlin/xcuitest/installer/LocalXCTestInstaller.kt Adds iproxy lifecycle management to forward the XCTest server port for real devices.
maestro-cli/build.gradle.kts Includes MaestroDriverLib/** in jar packaging and test resources.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +108 to +110
// devicectl doesn't have a direct "get pid by bundle id" command,
// so we use the running app list approach
return null
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearAppState() calls stop(id), but stop() currently always becomes a no-op because getAppPid() unconditionally returns null (so the terminate command returns early). This makes both stop() and clearAppState() ineffective for real devices.

Implement PID resolution (e.g., by querying devicectl for running processes and matching the bundle id) or use a devicectl process terminate option that can terminate by bundle identifier if available, and make stop() fail/return a clear error when termination can’t be performed.

Suggested change
// devicectl doesn't have a direct "get pid by bundle id" command,
// so we use the running app list approach
return null
// Resolve the PID of a running app by bundle identifier using `devicectl`.
// We query the list of processes for this device and look for a line containing
// the bundle id, then extract the first numeric token on that line as the PID.
return try {
val output = CommandLineUtils.runCommand(
listOf(
"xcrun",
"devicectl",
"device",
"process",
"list",
"--device",
deviceId,
)
)
val lines = output.lineSequence()
for (line in lines) {
if (!line.contains(bundleId)) {
continue
}
// Heuristic: take the first integer on the matching line as the PID.
val match = Regex("""\b(\d+)\b""").find(line)
val pid = match?.groupValues?.getOrNull(1)?.toLongOrNull()
if (pid != null) {
return pid
}
}
logger.warn("Unable to find PID for bundle id $bundleId on device $deviceId")
null
} catch (e: Exception) {
logger.warn("Failed to resolve PID for bundle id $bundleId on device $deviceId: ${e.message}")
null
}

Copilot uses AI. Check for mistakes.
override fun clearKeychain(): Result<Unit, Throwable> {
TODO("Not yet implemented")
logger.info("clearKeychain is not supported on real devices")
return Ok(Unit)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearKeychain() returns Ok(Unit) even though the method logs that it is not supported on real devices. This can mislead callers into thinking keychain state was actually cleared (especially since other unsupported capabilities in this class return Err(UnsupportedOperationException)).

Consider returning Err(UnsupportedOperationException) (or another explicit failure) for consistency, or ensure upstream code never relies on keychain having been cleared when this returns success.

Suggested change
return Ok(Unit)
return Err(UnsupportedOperationException("clearKeychain is not supported on real devices"))

Copilot uses AI. Check for mistakes.
Comment on lines 194 to 221
private fun startXCTestRunner(deviceId: String, preBuiltRunner: Boolean) {
if (isChannelAlive()) {
logger.info("UI Test runner already running, returning")
return
}

val buildProducts = iosBuildProductsExtractor.extract(iOSDriverConfig.sourceDirectory)

if (preBuiltRunner) {
logger.info("Installing pre built driver without xcodebuild")
installPrebuiltRunner(deviceId, buildProducts.uiRunnerPath)
} else {
logger.info("Installing driver with xcodebuild")
logger.info("[Start] Running XcUITest with `xcodebuild test-without-building` with $defaultPort and config: $iOSDriverConfig")
xcTestProcess = xcRunnerCLIUtils.runXcTestWithoutBuild(
deviceId = this.deviceId,
xcTestRunFilePath = buildProducts.xctestRunPath.absolutePath,
port = defaultPort,
snapshotKeyHonorModalViews = iOSDriverConfig.snapshotKeyHonorModalViews
)
logger.info("[Done] Running XcUITest with `xcodebuild test-without-building`")

// For real devices, the XCTest HTTP server runs on the device's loopback.
// We need iproxy to forward the port from Mac localhost to the device over USB.
if (deviceType == IOSDeviceType.REAL) {
startIproxy(defaultPort)
}
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iproxy port forwarding is only started in the xcodebuild test-without-building path (when preBuiltRunner == false). For real devices the HTTP server is still on the device loopback regardless of how the runner was launched, so the preBuiltRunner == true path will still be unreachable and start() will time out.

Consider starting port forwarding whenever deviceType == IOSDeviceType.REAL (before waiting for isChannelAlive()), and stopping it in close() accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +86
// Pass launch arguments
for ((key, value) in launchArguments) {
command.add("--$key")
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

launch() builds app arguments as --$key value, which diverges from the established iOS launch-argument formatting used for simulators (IOSLaunchArguments.toIOSLaunchArguments() produces isCartScreen true, -cartValue 3, etc.). With the current implementation, keys like cartValue will become --cartValue, and keys that already start with - will become ---cartValue, which is very likely to be parsed incorrectly by devicectl (as flags) and not forwarded to the app as intended.

To keep behavior consistent across simulators and real devices, reuse the existing toIOSLaunchArguments() conversion (and pass them as app arguments in whatever devicectl-supported way, e.g., after an argument separator if required).

Suggested change
// Pass launch arguments
for ((key, value) in launchArguments) {
command.add("--$key")
// Pass launch arguments using iOS-style formatting
for ((key, value) in launchArguments) {
val argKey = when (value) {
is Boolean -> key
else -> if (key.startsWith("-")) key else "-$key"
}
command.add(argKey)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants