From a651d088a0511dd77bc3ed3e6797c8e90fb4c927 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Wed, 28 Oct 2026 13:45:45 +0100 Subject: [PATCH] feat(ios): delegate clearState / clearKeychain on real devices to a host hook Physical iPhones cannot wipe an app's container or keychain in place, so clearState was a logged no-op that still reported COMPLETED. When MAESTRO_IOS_DEVICE_RESET_HOOK points at an executable, DeviceControlIOSDevice runs ' clear-state ' and ' clear-keychain' and fails the command on a non-zero exit. Without the hook the old behaviour is unchanged. --- .../ios/devicectl/DeviceControlIOSDevice.kt | 19 +++- .../java/ios/devicectl/DeviceResetHook.kt | 87 ++++++++++++++++++ .../ios/devicectl/DeviceResetHookTest.kt | 89 +++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 maestro-ios/src/main/java/ios/devicectl/DeviceResetHook.kt create mode 100644 maestro-ios/src/test/kotlin/ios/devicectl/DeviceResetHookTest.kt diff --git a/maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt b/maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt index b0c33168df..3556cd2b4d 100644 --- a/maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt +++ b/maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt @@ -1,5 +1,6 @@ package ios.devicectl +import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import device.IOSDevice @@ -12,7 +13,10 @@ import xcuitest.api.DeviceInfo import xcuitest.installer.LocalXCTestInstaller import java.io.InputStream -class DeviceControlIOSDevice(override val deviceId: String) : IOSDevice { +class DeviceControlIOSDevice( + override val deviceId: String, + private val resetHook: DeviceResetHook? = DeviceResetHook.fromEnvironment(), +) : IOSDevice { private val localIOSDevice by lazy { LocalIOSDevice() } @@ -57,6 +61,10 @@ class DeviceControlIOSDevice(override val deviceId: String) : IOSDevice { } override fun clearAppState(id: String) { + if (resetHook != null) { + resetHook.clearState(id) + return + } logger.warn( "clearState is not supported on real iOS devices due to iOS security restrictions. " + "App state will NOT be cleared. " + @@ -67,7 +75,14 @@ class DeviceControlIOSDevice(override val deviceId: String) : IOSDevice { } override fun clearKeychain(): Result { - return Ok(Unit) + if (resetHook == null) { + return Ok(Unit) + } + return try { + Ok(resetHook.clearKeychain()) + } catch (e: Exception) { + Err(e) + } } override fun launch(id: String, launchArguments: Map) { diff --git a/maestro-ios/src/main/java/ios/devicectl/DeviceResetHook.kt b/maestro-ios/src/main/java/ios/devicectl/DeviceResetHook.kt new file mode 100644 index 0000000000..fa3339d290 --- /dev/null +++ b/maestro-ios/src/main/java/ios/devicectl/DeviceResetHook.kt @@ -0,0 +1,87 @@ +package ios.devicectl + +import org.slf4j.LoggerFactory +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Delegates app-state and keychain resets on real iOS devices to an external command. + * + * iOS gives no way to wipe an app's data container or keychain items in place on a physical + * device, so the only reliable reset is reinstalling the app (and, for the keychain, running a + * helper signed with the app's bundle id). That needs the signed ipa and the signing setup, + * which live with whoever provisioned the device, not with Maestro. The host points + * [ENV_VAR] at an executable that is invoked as: + * + * clear-state + * clear-keychain + * + * A zero exit means the reset happened; anything else fails the command with the hook's output. + */ +class DeviceResetHook( + private val hookPath: String, + private val timeoutMs: Long = DEFAULT_TIMEOUT_MS, +) { + + fun clearState(bundleId: String) = run("clear-state", bundleId) + + fun clearKeychain() = run("clear-keychain") + + private fun run(vararg args: String) { + val command = listOf(hookPath) + args + logger.info("Running device reset hook: ${command.joinToString(" ")}") + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + process.outputStream.close() + + // Read on a separate thread so a hook that never exits cannot block us past the timeout. + val tail = ArrayDeque() + val reader = Thread { + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + logger.info("[reset hook] $line") + synchronized(tail) { + tail.addLast(line) + if (tail.size > TAIL_LINES) tail.removeFirst() + } + } + } + }.apply { isDaemon = true; start() } + + if (!process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { + process.destroyForcibly() + throw IllegalStateException("Device reset hook '${args.joinToString(" ")}' timed out after ${timeoutMs / 1000}s") + } + reader.join(READER_JOIN_MS) + + val exitCode = process.exitValue() + if (exitCode != 0) { + val output = synchronized(tail) { tail.joinToString("\n") } + throw IllegalStateException( + "Device reset hook '${args.joinToString(" ")}' failed with exit code $exitCode" + + if (output.isNotBlank()) ":\n$output" else "" + ) + } + } + + companion object { + const val ENV_VAR = "MAESTRO_IOS_DEVICE_RESET_HOOK" + + // Reinstalling a large ipa over USB can take a few minutes. + private const val DEFAULT_TIMEOUT_MS = 5 * 60 * 1000L + private const val TAIL_LINES = 20 + private const val READER_JOIN_MS = 2000L + + private val logger = LoggerFactory.getLogger(DeviceResetHook::class.java) + + fun fromEnvironment(env: Map = System.getenv()): DeviceResetHook? { + val path = env[ENV_VAR]?.takeIf { it.isNotBlank() } ?: return null + if (!File(path).canExecute()) { + logger.warn("$ENV_VAR is set to '$path', which is not an executable file; ignoring it") + return null + } + return DeviceResetHook(path) + } + } +} diff --git a/maestro-ios/src/test/kotlin/ios/devicectl/DeviceResetHookTest.kt b/maestro-ios/src/test/kotlin/ios/devicectl/DeviceResetHookTest.kt new file mode 100644 index 0000000000..64ae9a095c --- /dev/null +++ b/maestro-ios/src/test/kotlin/ios/devicectl/DeviceResetHookTest.kt @@ -0,0 +1,89 @@ +package ios.devicectl + +import com.github.michaelbull.result.getError +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class DeviceResetHookTest { + + @TempDir + lateinit var tempDir: File + + private fun hook(body: String): File { + val script = File(tempDir, "hook.sh") + script.writeText("#!/bin/sh\n$body\n") + script.setExecutable(true) + return script + } + + @Test + fun `clear-state passes the action and bundle id`() { + val calls = File(tempDir, "calls.txt") + val script = hook("echo \"$@\" >> '${calls.path}'") + + DeviceResetHook(script.path).clearState("com.example.app") + + assertThat(calls.readText().trim()).isEqualTo("clear-state com.example.app") + } + + @Test + fun `a failing hook throws with its output`() { + val script = hook("echo 'no ipa for com.example.other'; exit 3") + + val error = assertThrows { + DeviceResetHook(script.path).clearState("com.example.other") + } + + assertThat(error.message).contains("exit code 3") + assertThat(error.message).contains("no ipa for com.example.other") + } + + @Test + fun `a hung hook times out`() { + val script = hook("sleep 30") + + val error = assertThrows { + DeviceResetHook(script.path, timeoutMs = 300).clearKeychain() + } + + assertThat(error.message).contains("timed out") + } + + @Test + fun `fromEnvironment ignores an unset or non-executable path`() { + val notExecutable = File(tempDir, "plain.txt").apply { writeText("x") } + + assertThat(DeviceResetHook.fromEnvironment(emptyMap())).isNull() + assertThat(DeviceResetHook.fromEnvironment(mapOf(DeviceResetHook.ENV_VAR to notExecutable.path))).isNull() + assertThat(DeviceResetHook.fromEnvironment(mapOf(DeviceResetHook.ENV_VAR to hook("true").path))).isNotNull() + } + + @Test + fun `real device delegates clearState and clearKeychain to the hook`() { + val calls = File(tempDir, "calls.txt") + val device = DeviceControlIOSDevice("udid", DeviceResetHook(hook("echo \"$@\" >> '${calls.path}'").path)) + + device.clearAppState("com.example.app") + device.clearKeychain() + + assertThat(calls.readLines()).containsExactly("clear-state com.example.app", "clear-keychain").inOrder() + } + + @Test + fun `real device surfaces a failed keychain reset as an error result`() { + val device = DeviceControlIOSDevice("udid", DeviceResetHook(hook("exit 1").path)) + + assertThat(device.clearKeychain().getError()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `real device without a hook keeps the old no-op behaviour`() { + val device = DeviceControlIOSDevice("udid", resetHook = null) + + device.clearAppState("com.example.app") + assertThat(device.clearKeychain().getError()).isNull() + } +}