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
19 changes: 17 additions & 2 deletions maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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() }

Expand Down Expand Up @@ -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. " +
Expand All @@ -67,7 +75,14 @@ class DeviceControlIOSDevice(override val deviceId: String) : IOSDevice {
}

override fun clearKeychain(): Result<Unit, Throwable> {
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<String, Any>) {
Expand Down
87 changes: 87 additions & 0 deletions maestro-ios/src/main/java/ios/devicectl/DeviceResetHook.kt
Original file line number Diff line number Diff line change
@@ -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:
*
* <hook> clear-state <bundleId>
* <hook> 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<String>()
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<String, String> = 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)
}
}
}
89 changes: 89 additions & 0 deletions maestro-ios/src/test/kotlin/ios/devicectl/DeviceResetHookTest.kt
Original file line number Diff line number Diff line change
@@ -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<IllegalStateException> {
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<IllegalStateException> {
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()
}
}
Loading