Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: reinstall ios app to clear state reliably #2118

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
133 changes: 115 additions & 18 deletions maestro-ios-driver/src/main/kotlin/util/LocalSimulatorUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import com.fasterxml.jackson.module.kotlin.readValue
import maestro.utils.MaestroTimer
import org.rauschig.jarchivelib.ArchiveFormat
import org.rauschig.jarchivelib.ArchiverFactory
import org.slf4j.LoggerFactory
import util.CommandLineUtils.runCommand
import java.io.File
import java.io.InputStream
import java.lang.ProcessBuilder.Redirect.PIPE
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.createTempDirectory

object LocalSimulatorUtils {
Expand All @@ -17,6 +21,8 @@ object LocalSimulatorUtils {

private val homedir = System.getProperty("user.home")

private val logger = LoggerFactory.getLogger(LocalSimulatorUtils::class.java)

private val allPermissions = listOf(
"calendar",
"camera",
Expand Down Expand Up @@ -171,30 +177,109 @@ object LocalSimulatorUtils {
.waitFor()
}

private fun isAppRunning(deviceId: String, bundleId: String): Boolean {
val process = ProcessBuilder(
listOf(
"xcrun",
"simctl",
"spawn",
deviceId,
"launchctl",
"list",
)
).start()

return String(process.inputStream.readBytes()).trimEnd().contains(bundleId)
}

private fun ensureStopped(deviceId: String, bundleId: String) {
MaestroTimer.withTimeout(10000) {
while (true) {
if (isAppRunning(deviceId, bundleId)) {
Thread.sleep(1000)
} else {
return@withTimeout
}
}
} ?: throw SimctlError("App $bundleId did not stop in time")
}

private fun ensureRunning(deviceId: String, bundleId: String) {
MaestroTimer.withTimeout(10000) {
while (true) {
if (isAppRunning(deviceId, bundleId)) {
return@withTimeout
} else {
Thread.sleep(1000)
}
}
} ?: throw SimctlError("App $bundleId did not start in time")
}

private fun copyDirectoryRecursively(source: Path, target: Path) {
Files.walk(source).forEach { path ->
val targetPath = target.resolve(source.relativize(path).toString())
if (Files.isDirectory(path)) {
Files.createDirectories(targetPath)
} else {
Files.copy(path, targetPath)
}
}
}

private fun deleteFolderRecursively(folder: File): Boolean {
if (folder.isDirectory) {
folder.listFiles()?.forEach { child ->
deleteFolderRecursively(child)
}
}
return folder.delete()
}

private fun reinstallApp(deviceId: String, bundleId: String) {
val pathToBinary = Path(getAppBinaryDirectory(deviceId, bundleId))

if (Files.isDirectory(pathToBinary)) {
val tmpDir = createTempDirectory()
val tmpBundlePath = tmpDir.resolve("$bundleId-${System.currentTimeMillis()}.app")

logger.info("Copying app binary from $pathToBinary to $tmpBundlePath")
Files.copy(pathToBinary, tmpBundlePath)
copyDirectoryRecursively(pathToBinary, tmpBundlePath)

logger.info("Reinstalling and launching $bundleId")
uninstall(deviceId, bundleId)
install(deviceId, tmpBundlePath)
deleteFolderRecursively(tmpBundlePath.toFile())
logger.info("App $bundleId reinstalled and launched")
} else {
throw SimctlError("Could not find app binary for bundle $bundleId at $pathToBinary")
}
}

fun clearAppState(deviceId: String, bundleId: String) {
logger.info("Clearing app $bundleId state")
// Stop the app before clearing the file system
// This prevents the app from saving its state after it has been cleared
terminate(deviceId, bundleId)
ensureStopped(deviceId, bundleId)

// Wait for the app to be stopped
Thread.sleep(1500)

// deletes app data, including container folder
val appDataDirectory = getApplicationDataDirectory(deviceId, bundleId)
ProcessBuilder(listOf("rm", "-rf", appDataDirectory)).start().waitFor()

// forces app container folder to be re-created
val paths = listOf(
"Documents",
"Library",
"Library/Caches",
"Library/Preferences",
"SystemData",
"tmp"
)
// reinstall the app as that is the most stable way to clear state
reinstallApp(deviceId, bundleId)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This could break the current maestro flows for all the users right? How do we plan to roll this out?

Do we wait to bring on cloud/robin or we do it right away?

Should this be in next release or we include in this current version of CLI as 1.39.1?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I am torn - the fact is that clearState is not working properly today, so the fact that we fix it means that flows that implicitly rely on the broken state of clearState might stop working. However, it's a bug fix and not a breaking change so doing a major version bump doesn't feel right. Thoughts?

Copy link
Collaborator

@igorsmotto igorsmotto Nov 1, 2024

Choose a reason for hiding this comment

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

This could break the current maestro flows for all the users right?

Wait, how? Like, do you have any idea? We could potentially test this more to reduce this risk

Copy link
Collaborator

@igorsmotto igorsmotto Nov 1, 2024

Choose a reason for hiding this comment

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

About versioning, to me it's a bug fix. Clear state being flaky since forever is a bug


val command = listOf("mkdir", appDataDirectory) + paths.map { "$appDataDirectory/$it" }
ProcessBuilder(command).start().waitFor()
private fun getAppBinaryDirectory(deviceId: String, bundleId: String): String {
val process = ProcessBuilder(
listOf(
"xcrun",
"simctl",
"get_app_container",
deviceId,
bundleId,
)
).start()

return String(process.inputStream.readBytes()).trimEnd()
}

private fun getApplicationDataDirectory(deviceId: String, bundleId: String): String {
Expand Down Expand Up @@ -491,6 +576,18 @@ object LocalSimulatorUtils {
}
}

fun install(deviceId: String, path: Path) {
runCommand(
listOf(
"xcrun",
"simctl",
"install",
deviceId,
path.toAbsolutePath().toString(),
)
)
}

fun install(deviceId: String, stream: InputStream) {
val temp = createTempDirectory()
val extractDir = temp.toFile()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ class Orchestra(
maestro.setPermissions(command.appId, permissions)

} catch (e: Exception) {
throw MaestroException.UnableToClearState("Unable to clear state for app ${command.appId}")
throw MaestroException.UnableToClearState("Unable to clear state for app ${command.appId}: ${e.message}")
}

try {
Expand Down
Loading