fix(sidecar): harden android driver open, input guard, pressKey, foreground marker

- openWithRetry rebuilt a closed AndroidDriver, whose gRPC channel is final and shut down by close(); the retry then ran against a dead channel. Build a fresh driver per attempt and extract a unit-tested retryOpen helper (named DRIVER_OPEN_ATTEMPTS/BACKOFF).
- pressKey on the Maestro backend did KEY_MAP[key] (no lowercase, no throw), silently dropping unknown or wrong-case keys; route through a pure maestroKeyFor that lowercases and rejects unknown keys like the Stub contract.
- the mid-type foreground guard (typeShellSafe) was untested; extract a pure typeChunks and cover stop-on-foreground-change, always-send-first-chunk, and unknown-owner.
- foreground detection required the literal topResumedActivity=ActivityRecord; align parseResumedPackage to the same *ResumedActivity marker set Go reads so OEM wording does not disable the guard.
This commit is contained in:
pj committed 2026-06-11 09:23:41 +05:30
1 parent d45f5d4869
commit e7f7afcf32
3 files changed
+596 -168

No files matched your search

@@ -1,7 +1,11 @@
package dev.sanderling.sidecar package dev.sanderling.sidecar
interface DriverBackend { interface DriverBackend {
fun launch(bundleId: String, clearState: Boolean, env: Map<String, String> = emptyMap()) fun launch(
bundleId: String,
clearState: Boolean,
env: Map<String, String> = emptyMap(),
)
fun terminate(bundleId: String) fun terminate(bundleId: String)
fun tap(x: Int, y: Int) fun tap(x: Int, y: Int)
@@ -106,8 +110,11 @@ internal fun stabilitySnapshot(treeJson: String): String? {
} }
private val ROUTE_TAG_KEYS = setOf( private val ROUTE_TAG_KEYS = setOf(
"resource-id", "resourceId", "testTag", "resource-id",
"identifier", "accessibilityIdentifier", "resourceId",
"testTag",
"identifier",
"accessibilityIdentifier",
) )
internal fun countRouteScreens(treeJson: String): Int { internal fun countRouteScreens(treeJson: String): Int {
@@ -121,7 +128,9 @@ internal fun countRouteScreens(treeJson: String): Int {
} }
} }
private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): Int { private fun countRouteScreens(
node: com.fasterxml.jackson.databind.JsonNode,
): Int {
var count = 0 var count = 0
val attributes = node.get("attributes") val attributes = node.get("attributes")
if (attributes != null && attributes.isObject) { if (attributes != null && attributes.isObject) {
@@ -167,7 +176,10 @@ private val STABLE_ATTRIBUTE_KEYS = listOf(
"testTag", "identifier", "accessibilityIdentifier", "testTag", "identifier", "accessibilityIdentifier",
) )
private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode, out: StringBuilder) { private fun walkForStructuralHash(
node: com.fasterxml.jackson.databind.JsonNode,
out: StringBuilder,
) {
out.append('(') out.append('(')
val attributes = node.get("attributes") val attributes = node.get("attributes")
if (attributes != null && attributes.isObject) { if (attributes != null && attributes.isObject) {
@@ -191,7 +203,9 @@ private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode,
// then lands sequentially after the surviving one instead of failing the // then lands sequentially after the surviving one instead of failing the
// step. // step.
internal fun overlappedDoubleTap(tapAction: () -> Unit) { internal fun overlappedDoubleTap(tapAction: () -> Unit) {
val firstTap = java.util.concurrent.CompletableFuture.runAsync { tapAction() } val firstTap = java.util.concurrent.CompletableFuture.runAsync {
tapAction()
}
Thread.sleep(40) Thread.sleep(40)
try { try {
tapAction() tapAction()
@@ -220,16 +234,28 @@ data class LogLine(
val message: String, val message: String,
) )
internal fun readLogcat(serial: String?, sinceUnixMillis: Long, minLevel: String): List<LogLine> { internal fun readLogcat(
serial: String?,
sinceUnixMillis: Long,
minLevel: String,
): List<LogLine> {
val level = if (minLevel.isEmpty()) "E" else minLevel val level = if (minLevel.isEmpty()) "E" else minLevel
val since = if (sinceUnixMillis > 0) StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis) else null val since = if (sinceUnixMillis >
0
) {
StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis)
} else {
null
}
val arguments = mutableListOf("logcat", "-d", "*:$level") val arguments = mutableListOf("logcat", "-d", "*:$level")
if (since != null) { if (since != null) {
arguments.add("-T") arguments.add("-T")
arguments.add(since) arguments.add(since)
} }
return try { return try {
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start() val process = ProcessBuilder(
adbCmd(serial) + arguments,
).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText() val output = process.inputStream.bufferedReader().readText()
process.waitFor() process.waitFor()
StubDriverBackend.parseLogcatOutput(output) StubDriverBackend.parseLogcatOutput(output)
@@ -257,15 +283,15 @@ internal fun readProcMetrics(serial: String?, bundleId: String): MetricsSample {
private fun adbCmd(serial: String?): List<String> = private fun adbCmd(serial: String?): List<String> =
if (serial == null) listOf("adb") else listOf("adb", "-s", serial) if (serial == null) listOf("adb") else listOf("adb", "-s", serial)
private fun adbOutput(serial: String?, arguments: List<String>): String { private fun adbOutput(serial: String?, arguments: List<String>): String = try {
return try { val process = ProcessBuilder(
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start() adbCmd(serial) + arguments,
val output = process.inputStream.bufferedReader().readText() ).redirectErrorStream(false).start()
process.waitFor() val output = process.inputStream.bufferedReader().readText()
output process.waitFor()
} catch (cause: Exception) { output
"" } catch (cause: Exception) {
} ""
} }
private fun sampleCpuTwice(serial: String?, pid: Int): Double { private fun sampleCpuTwice(serial: String?, pid: Int): Double {
@@ -276,8 +302,14 @@ private fun sampleCpuTwice(serial: String?, pid: Int): Double {
if (lines.size < 2) return 0.0 if (lines.size < 2) return 0.0
val first = parseCpuTicks(lines[0]) ?: return 0.0 val first = parseCpuTicks(lines[0]) ?: return 0.0
val second = parseCpuTicks(lines[1]) ?: return 0.0 val second = parseCpuTicks(lines[1]) ?: return 0.0
val clockHz = adbOutput(serial, listOf("shell", "getconf", "CLK_TCK")).trim().toLongOrNull() ?: 100L val clockHz =
val deltaCpuNanos = (second - first) * 1_000_000_000.0 / clockHz.coerceAtLeast(1L) adbOutput(
serial,
listOf("shell", "getconf", "CLK_TCK"),
).trim().toLongOrNull()
?: 100L
val deltaCpuNanos =
(second - first) * 1_000_000_000.0 / clockHz.coerceAtLeast(1L)
return (deltaCpuNanos / 50_000_000.0) * 100.0 return (deltaCpuNanos / 50_000_000.0) * 100.0
} }
@@ -312,7 +344,9 @@ internal fun parseKb(line: String): Long? {
private fun execAdb(arguments: List<String>) { private fun execAdb(arguments: List<String>) {
try { try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() val command = ProcessBuilder(
listOf("adb") + arguments,
).redirectErrorStream(true).start()
command.inputStream.bufferedReader().readText() command.inputStream.bufferedReader().readText()
command.waitFor() command.waitFor()
} catch (cause: Exception) { } catch (cause: Exception) {
@@ -326,22 +360,39 @@ class StubDriverBackend(
) : DriverBackend { ) : DriverBackend {
@Volatile var launchCount: Int = 0 @Volatile var launchCount: Int = 0
private set private set
@Volatile var lastBundleId: String? = null @Volatile var lastBundleId: String? = null
private set private set
@Volatile var lastTap: Pair<Int, Int>? = null @Volatile var lastTap: Pair<Int, Int>? = null
private set private set
@Volatile var lastTapSelector: String? = null @Volatile var lastTapSelector: String? = null
private set private set
@Volatile var lastInputText: String? = null @Volatile var lastInputText: String? = null
private set private set
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) { override fun launch(
bundleId: String,
clearState: Boolean,
env: Map<String, String>,
) {
launchCount++ launchCount++
lastBundleId = bundleId lastBundleId = bundleId
if (clearState) { if (clearState) {
runAdb(listOf("shell", "pm", "clear", bundleId)) runAdb(listOf("shell", "pm", "clear", bundleId))
} }
runAdb(listOf("shell", "am", "start", "-W", "-n", "$bundleId/.MainActivity")) runAdb(
listOf(
"shell",
"am",
"start",
"-W",
"-n",
"$bundleId/.MainActivity",
),
)
} }
companion object { companion object {
@@ -350,7 +401,10 @@ class StubDriverBackend(
internal fun isAnimationCountIdle(grepOutput: String): Boolean = internal fun isAnimationCountIdle(grepOutput: String): Boolean =
(grepOutput.trim().toIntOrNull() ?: 0) == 0 (grepOutput.trim().toIntOrNull() ?: 0) == 0
internal fun parseResolvedActivity(bundleId: String, output: String): String? { internal fun parseResolvedActivity(
bundleId: String,
output: String,
): String? {
val prefix = "$bundleId/" val prefix = "$bundleId/"
for (line in output.lines()) { for (line in output.lines()) {
val trimmed = line.trim() val trimmed = line.trim()
@@ -366,8 +420,13 @@ class StubDriverBackend(
for (ch in text) { for (ch in text) {
when (ch) { when (ch) {
' ' -> sb.append("%s") ' ' -> sb.append("%s")
'\\', '"', '\'', '&', '|', ';', '<', '>', '(', ')', '*', '?', '\\', '"', '\'', '&', '|', ';', '<', '>', '(', ')', '*', '?',
'$', '`', '[', ']', '{', '}', '~', '#', -> sb.append('\\').append(ch) '$', '`', '[', ']', '{', '}', '~', '#',
-> sb.append(
'\\',
).append(ch)
else -> sb.append(ch) else -> sb.append(ch)
} }
} }
@@ -452,19 +511,31 @@ class StubDriverBackend(
@Volatile var lastSwipe: SwipeRecord? = null @Volatile var lastSwipe: SwipeRecord? = null
private set private set
@Volatile var lastKey: String? = null @Volatile var lastKey: String? = null
private set private set
@Volatile var lastLongPress: Pair<Int, Int>? = null @Volatile var lastLongPress: Pair<Int, Int>? = null
private set private set
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) { override fun swipe(
fromX: Int,
fromY: Int,
toX: Int,
toY: Int,
durationMillis: Long,
) {
lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis) lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis)
val effectiveDuration = if (durationMillis > 0) durationMillis else 250L val effectiveDuration = if (durationMillis > 0) durationMillis else 250L
runAdb( runAdb(
listOf( listOf(
"shell", "input", "swipe", "shell",
fromX.toString(), fromY.toString(), "input",
toX.toString(), toY.toString(), "swipe",
fromX.toString(),
fromY.toString(),
toX.toString(),
toY.toString(),
effectiveDuration.toString(), effectiveDuration.toString(),
), ),
) )
@@ -473,51 +544,71 @@ class StubDriverBackend(
override fun pressKey(key: String) { override fun pressKey(key: String) {
lastKey = key lastKey = key
val keyCode = KEY_MAP[key.lowercase()] val keyCode = KEY_MAP[key.lowercase()]
?: throw IllegalArgumentException("unsupported pressKey value: $key") ?: throw IllegalArgumentException(
"unsupported pressKey value: $key",
)
runAdb(listOf("shell", "input", "keyevent", keyCode)) runAdb(listOf("shell", "input", "keyevent", keyCode))
} }
override fun longPress(x: Int, y: Int) { override fun longPress(x: Int, y: Int) {
lastLongPress = x to y lastLongPress = x to y
runAdb(listOf("shell", "input", "swipe", x.toString(), y.toString(), x.toString(), y.toString(), "600")) runAdb(
listOf(
"shell",
"input",
"swipe",
x.toString(),
y.toString(),
x.toString(),
y.toString(),
"600",
),
)
} }
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = override fun recentLogs(
readLogcat(null, sinceUnixMillis, minLevel) sinceUnixMillis: Long,
minLevel: String,
): List<LogLine> = readLogcat(null, sinceUnixMillis, minLevel)
data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long) data class SwipeRecord(
val fromX: Int,
val fromY: Int,
val toX: Int,
val toY: Int,
val durationMillis: Long,
)
private fun runAdb(arguments: List<String>) = commandRunner(arguments) private fun runAdb(arguments: List<String>) = commandRunner(arguments)
override fun screenshot(): Triple<ByteArray, Int, Int> { override fun screenshot(): Triple<ByteArray, Int, Int> = try {
return try { val process = ProcessBuilder(
val process = ProcessBuilder(listOf("adb", "exec-out", "screencap", "-p")) listOf("adb", "exec-out", "screencap", "-p"),
.redirectErrorStream(false) )
.start() .redirectErrorStream(false)
val png = process.inputStream.readAllBytes() .start()
process.waitFor() val png = process.inputStream.readAllBytes()
if (png.isEmpty()) Triple(ByteArray(0), 0, 0) else Triple(png, 0, 0) process.waitFor()
} catch (cause: Exception) { if (png.isEmpty()) Triple(ByteArray(0), 0, 0) else Triple(png, 0, 0)
println("adb screencap failed: $cause") } catch (cause: Exception) {
Triple(ByteArray(0), 0, 0) println("adb screencap failed: $cause")
} Triple(ByteArray(0), 0, 0)
} }
override fun hierarchy(): String { override fun hierarchy(): String = try {
return try { val process = ProcessBuilder(
val process = ProcessBuilder( listOf(
listOf( "adb",
"adb", "exec-out", "exec-out",
"uiautomator dump /data/local/tmp/window_dump.xml >/dev/null 2>&1 && cat /data/local/tmp/window_dump.xml", "uiautomator dump /data/local/tmp/window_dump.xml >/dev/null 2>&1 && cat /data/local/tmp/window_dump.xml",
), ),
).redirectErrorStream(false).start() ).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText() val output = process.inputStream.bufferedReader().readText()
process.waitFor() process.waitFor()
if (output.isBlank()) "<hierarchy/>" else output if (output.isBlank()) "<hierarchy/>" else output
} catch (cause: Exception) { } catch (cause: Exception) {
println("adb uiautomator dump failed: $cause") println("adb uiautomator dump failed: $cause")
"<hierarchy/>" "<hierarchy/>"
}
} }
override fun waitForIdle(durationMillis: Long) { override fun waitForIdle(durationMillis: Long) {
@@ -532,21 +623,29 @@ class StubDriverBackend(
if (isDeviceIdle()) break if (isDeviceIdle()) break
Thread.sleep(IDLE_POLL_INTERVAL_MILLIS) Thread.sleep(IDLE_POLL_INTERVAL_MILLIS)
} }
pollUntilStable(STABILITY_POLL_CAP_MILLIS) { stabilitySnapshot(hierarchy()) } pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(hierarchy())
}
} }
private fun isDeviceIdle(): Boolean { private fun isDeviceIdle(): Boolean = try {
return try { val output =
val output = adbOutput(null, listOf("shell", "dumpsys window -a | grep -c mAnimating=true")) adbOutput(
isAnimationCountIdle(output) null,
} catch (cause: Exception) { listOf(
false "shell",
} "dumpsys window -a | grep -c mAnimating=true",
),
)
isAnimationCountIdle(output)
} catch (cause: Exception) {
false
} }
override fun healthy(): Boolean = true override fun healthy(): Boolean = true
override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId) override fun metrics(bundleId: String): MetricsSample =
readProcMetrics(null, bundleId)
} }
// FAST_INPUT_SAFE matches text that can be typed with adb `input text`: ASCII, // FAST_INPUT_SAFE matches text that can be typed with adb `input text`: ASCII,
@@ -583,38 +682,106 @@ internal fun chunkForInput(text: String, size: Int): List<String> {
return chunks return chunks
} }
class MaestroDriverBackend(private val serial: String?) : DriverBackend { // typeChunks sends each chunk via `send`, re-reading the foreground owner with
private val dadb: dadb.Dadb // `currentForeground` before every chunk after the first. If the owner changed
private val driver: maestro.drivers.AndroidDriver // from `startOwner`, it stops (returning the count already typed) so keystrokes
// never spray into a window that stole focus mid-type. The first chunk is always
init { // sent (there is nothing typed yet to leak), and a null startOwner (foreground
dadb = buildDadb(serial) // unknown) disables the check so typing proceeds. Returns the chars sent.
val hostPort = java.net.ServerSocket(0).use { it.localPort } internal fun typeChunks(
driver = maestro.drivers.AndroidDriver(dadb, hostPort) chunks: List<String>,
openWithRetry() startOwner: String?,
currentForeground: () -> String?,
send: (String) -> Unit,
): Int {
var typed = 0
for (chunk in chunks) {
if (startOwner != null && typed > 0 &&
currentForeground() != startOwner
) {
return typed
}
send(chunk)
typed += chunk.length
} }
return typed
}
// openWithRetry tolerates the maestro Android driver's occasional startup // resumedActivityPackage matches a "package/activity" component, mirroring the
// timeout (its instrumentation host can miss the dadb.open() deadline, // Go scope guard's regex so both read the same dumpsys wording.
// especially right after a device reboot or per-run reinstall). A transient private val resumedActivityPackage =
// failure should not abort the whole run, so retry a few times with a short Regex("""([a-zA-Z][a-zA-Z0-9_.]*)/[a-zA-Z0-9_.$]+""")
// backoff before giving up.
private fun openWithRetry() { // parseResumedPackage extracts the foreground package from `dumpsys activity
val attempts = 4 // activities` output. It reads any *ResumedActivity line (topResumedActivity=,
for (attempt in 1..attempts) { // mResumedActivity:, ResumedActivity:) rather than one OEM-specific phrasing, so
try { // a ROM that words the line differently does not silently disable the mid-type
driver.open() // foreground guard. Returns null when no such line is present.
return internal fun parseResumedPackage(dumpsys: String): String? {
} catch (cause: Exception) { for (line in dumpsys.lineSequence()) {
runCatching { driver.close() } if (!line.contains("ResumedActivity")) continue
if (attempt == attempts) throw cause resumedActivityPackage.find(line)?.let { return it.groupValues[1] }
System.err.println("android driver open failed (attempt $attempt/$attempts): ${cause.message}; retrying") }
Thread.sleep(2000) return null
} }
// DRIVER_OPEN_ATTEMPTS / DRIVER_OPEN_BACKOFF_MILLIS tune the retry around the
// maestro Android driver's occasional startup timeout (its instrumentation host
// can miss the open() deadline right after a reboot or per-run reinstall).
internal const val DRIVER_OPEN_ATTEMPTS = 4
internal const val DRIVER_OPEN_BACKOFF_MILLIS = 2000L
// retryOpen runs open() up to `attempts` times, sleeping `backoffMillis` between
// tries and rethrowing the last failure if none succeed. It holds no driver
// state (sleep and log are injectable) so the retry policy is unit testable.
internal fun <T> retryOpen(
attempts: Int,
backoffMillis: Long,
sleep: (Long) -> Unit = { Thread.sleep(it) },
log: (String) -> Unit = { System.err.println(it) },
open: () -> T,
): T {
var lastError: Exception? = null
for (attempt in 1..attempts) {
try {
return open()
} catch (cause: Exception) {
lastError = cause
if (attempt == attempts) break
log(
"android driver open failed (attempt $attempt/$attempts): ${cause.message}; retrying",
)
sleep(backoffMillis)
} }
} }
throw lastError
?: IllegalStateException("retryOpen called with attempts=$attempts")
}
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) { class MaestroDriverBackend(private val serial: String?) : DriverBackend {
private val dadb: dadb.Dadb = buildDadb(serial)
// A fresh AndroidDriver per open attempt. Its gRPC channel is built once in
// the constructor and permanently shut down by close(), so reopening a
// closed instance would reuse a dead channel; rebuild it each try instead.
private val driver: maestro.drivers.AndroidDriver =
retryOpen(DRIVER_OPEN_ATTEMPTS, DRIVER_OPEN_BACKOFF_MILLIS) {
val hostPort = java.net.ServerSocket(0).use { it.localPort }
val candidate = maestro.drivers.AndroidDriver(dadb, hostPort)
try {
candidate.open()
candidate
} catch (cause: Exception) {
runCatching { candidate.close() }
throw cause
}
}
override fun launch(
bundleId: String,
clearState: Boolean,
env: Map<String, String>,
) {
if (clearState) driver.clearAppState(bundleId) if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, env) driver.launchApp(bundleId, env)
} }
@@ -623,12 +790,18 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y)) override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y))
override fun longPress(x: Int, y: Int) = driver.longPress(maestro.Point(x, y)) override fun longPress(x: Int, y: Int) =
driver.longPress(maestro.Point(x, y))
override fun tapSelector(selector: String) { override fun tapSelector(selector: String) {
val root = driver.contentDescriptor(false) val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return val bounds = findBoundsBySelector(root, selector) ?: return
driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)) driver.tap(
maestro.Point(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
),
)
} }
override fun inputText(text: String) { override fun inputText(text: String) {
@@ -651,36 +824,45 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
// typing stops instead of leaking out of the app under test. // typing stops instead of leaking out of the app under test.
private fun typeShellSafe(text: String) { private fun typeShellSafe(text: String) {
val owner = foregroundPackage() val owner = foregroundPackage()
var typed = 0 val typed =
for (chunk in chunkForInput(text, INPUT_CHUNK_CHARS)) { typeChunks(chunkForInput(text, INPUT_CHUNK_CHARS), owner, {
if (owner != null && typed > 0 && foregroundPackage() != owner) { foregroundPackage()
System.err.println( }) { chunk ->
"warn: inputText stopped; foreground left $owner mid-type after $typed/${text.length} chars", dadb.shell("input text $chunk")
)
return
} }
dadb.shell("input text $chunk") if (typed < text.length) {
typed += chunk.length System.err.println(
"warn: inputText stopped; foreground left $owner mid-type after $typed/${text.length} chars",
)
} }
} }
// foregroundPackage returns the package of the top resumed activity, or null // foregroundPackage returns the package of the top resumed activity, or null
// if it cannot be read. Used to detect mid-type focus escapes. // if it cannot be read. Used to detect mid-type focus escapes.
private fun foregroundPackage(): String? { private fun foregroundPackage(): String? = parseResumedPackage(
val output = adbOutput(serial, listOf("shell", "dumpsys", "activity", "activities")) adbOutput(
return Regex("""topResumedActivity=ActivityRecord\{\S+ \S+ ([^/\s]+)/""") serial,
.find(output)?.groupValues?.get(1) listOf("shell", "dumpsys", "activity", "activities"),
} ),
)
override fun eraseText(characterCount: Int) = driver.eraseText(characterCount) override fun eraseText(characterCount: Int) =
driver.eraseText(characterCount)
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = override fun swipe(
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L)) fromX: Int,
fromY: Int,
toX: Int,
toY: Int,
durationMillis: Long,
) = driver.swipe(
maestro.Point(fromX, fromY),
maestro.Point(toX, toY),
maxOf(durationMillis, 250L),
)
override fun pressKey(key: String) { override fun pressKey(key: String) {
StubDriverBackend.KEY_MAP[key]?.let { keyCode -> maestroKeyFor(key)?.let { driver.pressKey(it) }
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
}
} }
override fun screenshot(): Triple<ByteArray, Int, Int> { override fun screenshot(): Triple<ByteArray, Int, Int> {
@@ -691,7 +873,9 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
} }
override fun hierarchy(): String = override fun hierarchy(): String =
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(driver.contentDescriptor(false)) com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(
driver.contentDescriptor(false),
)
override fun recentLogs(sinceUnixMillis: Long, minLevel: String) = override fun recentLogs(sinceUnixMillis: Long, minLevel: String) =
readLogcat(serial, sinceUnixMillis, minLevel) readLogcat(serial, sinceUnixMillis, minLevel)
@@ -706,7 +890,10 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
driver.waitForAppToSettle(null, null, durationMillis.toInt()) driver.waitForAppToSettle(null, null, durationMillis.toInt())
} }
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } override fun healthy() = runCatching {
driver.contentDescriptor(false)
true
}.getOrElse { false }
override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId) override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId)
@@ -727,16 +914,37 @@ internal sealed interface DadbTarget {
internal fun dadbTargetFor(serial: String?): DadbTarget { internal fun dadbTargetFor(serial: String?): DadbTarget {
if (serial == null) return DadbTarget.Tcp("localhost", 5555) if (serial == null) return DadbTarget.Tcp("localhost", 5555)
val colon = serial.lastIndexOf(':') val colon = serial.lastIndexOf(':')
val port = if (colon >= 0) serial.substring(colon + 1).toIntOrNull() else null val port = if (colon >=
return if (port != null) DadbTarget.Tcp(serial.substring(0, colon), port) else DadbTarget.Server(serial) 0
) {
serial.substring(colon + 1).toIntOrNull()
} else {
null
}
return if (port !=
null
) {
DadbTarget.Tcp(serial.substring(0, colon), port)
} else {
DadbTarget.Server(serial)
}
} }
private fun buildDadb(serial: String?): dadb.Dadb = when (val target = dadbTargetFor(serial)) { private fun buildDadb(serial: String?): dadb.Dadb =
is DadbTarget.Tcp -> dadb.Dadb.create(target.host, target.port) when (val target = dadbTargetFor(serial)) {
is DadbTarget.Server -> dadb.adbserver.AdbServer.createDadb("localhost", 5037, "host:transport:${target.serial}") is DadbTarget.Tcp -> dadb.Dadb.create(target.host, target.port)
}
internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? { is DadbTarget.Server -> dadb.adbserver.AdbServer.createDadb(
"localhost",
5037,
"host:transport:${target.serial}",
)
}
internal fun findBoundsBySelector(
root: maestro.TreeNode,
selector: String,
): IntArray? {
val colon = selector.indexOf(':') val colon = selector.indexOf(':')
if (colon < 0) return null if (colon < 0) return null
val kind = selector.substring(0, colon) val kind = selector.substring(0, colon)
@@ -744,13 +952,25 @@ internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): Int
return findBoundsInTree(root, kind, value) return findBoundsInTree(root, kind, value)
} }
internal fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? { internal fun findBoundsInTree(
node: maestro.TreeNode,
kind: String,
value: String,
): IntArray? {
val attrs = node.attributes val attrs = node.attributes
val matches = when (kind) { val matches = when (kind) {
"id" -> attrs["resource-id"]?.let { it == value || it.endsWith(":id/$value") } == true "id" -> attrs["resource-id"]?.let {
it == value ||
it.endsWith(":id/$value")
} ==
true
"text" -> attrs["text"] == value "text" -> attrs["text"] == value
"desc" -> attrs["content-desc"] == value "desc" -> attrs["content-desc"] == value
"descPrefix" -> attrs["content-desc"]?.startsWith(value) == true "descPrefix" -> attrs["content-desc"]?.startsWith(value) == true
else -> false else -> false
} }
if (matches) { if (matches) {
@@ -770,13 +990,15 @@ internal fun parseBounds(s: String): IntArray? {
internal fun pngWidth(bytes: ByteArray): Int { internal fun pngWidth(bytes: ByteArray): Int {
if (bytes.size < 24) return 0 if (bytes.size < 24) return 0
return (bytes[16].toInt() and 0xFF shl 24) or (bytes[17].toInt() and 0xFF shl 16) or return (bytes[16].toInt() and 0xFF shl 24) or
(bytes[17].toInt() and 0xFF shl 16) or
(bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF) (bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF)
} }
internal fun pngHeight(bytes: ByteArray): Int { internal fun pngHeight(bytes: ByteArray): Int {
if (bytes.size < 24) return 0 if (bytes.size < 24) return 0
return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or return (bytes[20].toInt() and 0xFF shl 24) or
(bytes[21].toInt() and 0xFF shl 16) or
(bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF)
} }
@@ -787,9 +1009,24 @@ internal const val IOS_XCTEST_RUNNER_BUNDLE_ID = "dev.mobile.maestro-driver-iosU
// session alive; xcodebuild later restarts its dead runner, which terminates // session alive; xcodebuild later restarts its dead runner, which terminates
// the active run's session and steals the simulator's gesture daemon. Returns // the active run's session and steals the simulator's gesture daemon. Returns
// true when an orphaned xcodebuild session was found and killed. // true when an orphaned xcodebuild session was found and killed.
internal fun reapOrphanIosRunners(udid: String, execute: (List<String>) -> Int): Boolean { internal fun reapOrphanIosRunners(
val killed = execute(listOf("pkill", "-f", "xcodebuild.*test-without-building.*$udid")) == 0 udid: String,
execute(listOf("xcrun", "simctl", "terminate", udid, IOS_XCTEST_RUNNER_BUNDLE_ID)) execute: (List<String>) -> Int,
): Boolean {
val killed =
execute(
listOf("pkill", "-f", "xcodebuild.*test-without-building.*$udid"),
) ==
0
execute(
listOf(
"xcrun",
"simctl",
"terminate",
udid,
IOS_XCTEST_RUNNER_BUNDLE_ID,
),
)
return killed return killed
} }
@@ -811,30 +1048,34 @@ internal class WdaRecovery(
// can fail client-side after the device already applied it, so replaying // can fail client-side after the device already applied it, so replaying
// types text or taps twice. Non-idempotent actions surface UNAVAILABLE, // types text or taps twice. Non-idempotent actions surface UNAVAILABLE,
// which the runner treats as transient. // which the runner treats as transient.
fun <T> run(replay: Boolean, block: () -> T): T { fun <T> run(replay: Boolean, block: () -> T): T = try {
return try { block()
} catch (e: Exception) {
if (!isIoFailure(e)) throw e
recover(e)
if (!replay) {
throw io.grpc.Status.UNAVAILABLE
.withDescription(
"connection dropped mid-action; the action may have applied: ${e.message}",
)
.withCause(e).asRuntimeException()
}
try {
block() block()
} catch (e: Exception) { } catch (retryErr: Exception) {
if (!isIoFailure(e)) throw e if (!isIoFailure(retryErr)) throw retryErr
recover(e) throw io.grpc.Status.UNAVAILABLE
if (!replay) { .withDescription(
throw io.grpc.Status.UNAVAILABLE "read retry failed after channel recovery: ${retryErr.message}",
.withDescription("connection dropped mid-action; the action may have applied: ${e.message}") )
.withCause(e).asRuntimeException() .withCause(retryErr).asRuntimeException()
}
try {
block()
} catch (retryErr: Exception) {
if (!isIoFailure(retryErr)) throw retryErr
throw io.grpc.Status.UNAVAILABLE
.withDescription("read retry failed after channel recovery: ${retryErr.message}")
.withCause(retryErr).asRuntimeException()
}
} }
} }
private fun isIoFailure(e: Exception): Boolean = private fun isIoFailure(e: Exception): Boolean =
generateSequence(e as Throwable) { it.cause }.any { it is java.io.IOException } generateSequence(e as Throwable) {
it.cause
}.any { it is java.io.IOException }
private fun recover(cause: Exception) { private fun recover(cause: Exception) {
lock.lock() lock.lock()
@@ -848,17 +1089,32 @@ internal class WdaRecovery(
try { try {
restart() restart()
} catch (restartErr: Exception) { } catch (restartErr: Exception) {
throw IllegalStateException("WDA reconnect failed: $restartErr", cause) throw IllegalStateException(
"WDA reconnect failed: $restartErr",
cause,
)
} }
log("XCTest runner restarted in ${System.currentTimeMillis() - startedAt} ms") log(
"XCTest runner restarted in ${System.currentTimeMillis() - startedAt} ms",
)
} finally { } finally {
lock.unlock() lock.unlock()
} }
} }
} }
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? { // maestroKeyFor resolves a logical key name to a maestro KeyCode. It lowercases
return when (adbKeyCode) { // and rejects an unknown name (matching StubDriverBackend's contract, so an
// unmapped or wrong-case key fails loudly instead of being silently dropped),
// and returns null only when the key is known but maestro has no enum for it.
internal fun maestroKeyFor(key: String): maestro.KeyCode? {
val keyCode = StubDriverBackend.KEY_MAP[key.lowercase()]
?: throw IllegalArgumentException("unsupported pressKey value: $key")
return keyCodeToMaestro(keyCode)
}
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? =
when (adbKeyCode) {
"KEYCODE_BACK" -> maestro.KeyCode.BACK "KEYCODE_BACK" -> maestro.KeyCode.BACK
"KEYCODE_HOME" -> maestro.KeyCode.HOME "KEYCODE_HOME" -> maestro.KeyCode.HOME
"KEYCODE_ENTER" -> maestro.KeyCode.ENTER "KEYCODE_ENTER" -> maestro.KeyCode.ENTER
@@ -869,4 +1125,3 @@ private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
"KEYCODE_DPAD_RIGHT" -> maestro.KeyCode.REMOTE_RIGHT "KEYCODE_DPAD_RIGHT" -> maestro.KeyCode.REMOTE_RIGHT
else -> null else -> null
} }
}
@@ -15,8 +15,19 @@ class InputTextTest {
// enough for keystrokes to spray into the launcher search box. A regression // enough for keystrokes to spray into the launcher search box. A regression
// here would corrupt edge-case input or shell-inject the device. // here would corrupt edge-case input or shell-inject the device.
@Test fun fastInputPathAcceptsShellSafeAsciiOfAnyLength() { @Test fun fastInputPathAcceptsShellSafeAsciiOfAnyLength() {
for (safe in listOf("[email protected]", "ledger123", "Checking", "1e10", "0.0000001", "42", "a".repeat(4096))) { for (safe in listOf(
assertTrue(FAST_INPUT_SAFE.matches(safe), "expected fast path for length ${safe.length}") "[email protected]",
"ledger123",
"Checking",
"1e10",
"0.0000001",
"42",
"a".repeat(4096),
)) {
assertTrue(
FAST_INPUT_SAFE.matches(safe),
"expected fast path for length ${safe.length}",
)
} }
val fallback = listOf( val fallback = listOf(
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--", "Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
@@ -24,7 +35,10 @@ class InputTextTest {
"-1", "-rf", // a leading dash could be read as an option by `input text` "-1", "-rf", // a leading dash could be read as an option by `input text`
) )
for (text in fallback) { for (text in fallback) {
assertTrue(!FAST_INPUT_SAFE.matches(text), "expected driver fallback for: $text") assertTrue(
!FAST_INPUT_SAFE.matches(text),
"expected driver fallback for: $text",
)
} }
} }
@@ -34,7 +48,12 @@ class InputTextTest {
val text = "a".repeat(4096) val text = "a".repeat(4096)
val chunks = chunkForInput(text, 512) val chunks = chunkForInput(text, 512)
assertEquals(text, chunks.joinToString("")) assertEquals(text, chunks.joinToString(""))
assertTrue(chunks.all { it.length <= 512 }, "no chunk may exceed the size") assertTrue(
chunks.all {
it.length <= 512
},
"no chunk may exceed the size",
)
assertTrue(chunks.size >= 8, "4096/512 should be at least 8 chunks") assertTrue(chunks.size >= 8, "4096/512 should be at least 8 chunks")
} }
@@ -42,7 +61,12 @@ class InputTextTest {
// a boundary that would fall on '-' is pushed past the dashes // a boundary that would fall on '-' is pushed past the dashes
val chunks = chunkForInput("ab--cd", 2) val chunks = chunkForInput("ab--cd", 2)
assertEquals("ab--cd", chunks.joinToString("")) assertEquals("ab--cd", chunks.joinToString(""))
assertTrue(chunks.drop(1).none { it.startsWith("-") }, "no later chunk may start with '-'") assertTrue(
chunks.drop(1).none {
it.startsWith("-")
},
"no later chunk may start with '-'",
)
} }
// A logical key name must map to the right Android keycode; a typo'd table // A logical key name must map to the right Android keycode; a typo'd table
@@ -56,7 +80,11 @@ class InputTextTest {
for ((key, keycode) in cases) { for ((key, keycode) in cases) {
val commands = mutableListOf<List<String>>() val commands = mutableListOf<List<String>>()
StubDriverBackend("android") { commands.add(it) }.pressKey(key) StubDriverBackend("android") { commands.add(it) }.pressKey(key)
assertEquals(listOf(listOf("shell", "input", "keyevent", keycode)), commands, key) assertEquals(
listOf(listOf("shell", "input", "keyevent", keycode)),
commands,
key,
)
} }
} }
@@ -73,7 +101,10 @@ class InputTextTest {
backend.inputText("Emergency Fund") backend.inputText("Emergency Fund")
assertEquals(listOf(listOf("shell", "input", "text", "Emergency%sFund")), commands) assertEquals(
listOf(listOf("shell", "input", "text", "Emergency%sFund")),
commands,
)
} }
@Test fun eraseTextSendsOneDeleteKeyPerCharacter() { @Test fun eraseTextSendsOneDeleteKeyPerCharacter() {
@@ -89,7 +120,10 @@ class InputTextTest {
} }
@Test fun escapeForAdbInputTextSubstitutesSpaces() { @Test fun escapeForAdbInputTextSubstitutesSpaces() {
assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world")) assertEquals(
"hello%sworld",
StubDriverBackend.escapeForAdbInputText("hello world"),
)
} }
@Test fun escapeForAdbInputTextEscapesShellMetacharacters() { @Test fun escapeForAdbInputTextEscapesShellMetacharacters() {
@@ -105,7 +139,93 @@ class InputTextTest {
@Test fun escapeForAdbInputTextLeavesSimpleTextAlone() { @Test fun escapeForAdbInputTextLeavesSimpleTextAlone() {
assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34")) assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34"))
assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee")) assertEquals(
"Coffee",
StubDriverBackend.escapeForAdbInputText("Coffee"),
)
assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5")) assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5"))
} }
// typeChunks sends every chunk while the foreground holds steady, so a normal
// type completes in full.
@Test fun typeChunksSendsAllChunksWhenForegroundStable() {
val sent = mutableListOf<String>()
val typed =
typeChunks(listOf("aaa", "bbb", "cc"), "app.folio", {
"app.folio"
}) { sent.add(it) }
assertEquals(listOf("aaa", "bbb", "cc"), sent)
assertEquals(8, typed)
}
// The core guard: once the app under test loses the foreground mid-type, the
// remaining chunks must NOT be sent (they would spray into the window that
// stole focus, e.g. the launcher search box).
@Test fun typeChunksStopsWhenForegroundLeavesMidType() {
val sent = mutableListOf<String>()
var calls = 0
// Foreground holds for the check before chunk 2, then goes foreign before
// chunk 3: two chunks land, the third is suppressed.
val typed = typeChunks(listOf("aaa", "bbb", "ccc"), "app.folio", {
calls++
if (calls == 1) "app.folio" else "com.android.launcher"
}) { sent.add(it) }
assertEquals(
listOf("aaa", "bbb"),
sent,
"typing must stop at the chunk after focus left",
)
assertEquals(6, typed)
}
// The first chunk is always sent: there is nothing typed yet to leak, so the
// guard must not check before the very first send.
@Test fun typeChunksAlwaysSendsFirstChunkEvenIfForegroundAlreadyForeign() {
val sent = mutableListOf<String>()
val typed =
typeChunks(listOf("aaa", "bbb"), "app.folio", {
"com.android.launcher"
}) { sent.add(it) }
assertEquals(listOf("aaa"), sent)
assertEquals(3, typed)
}
// A null start owner (foreground unreadable) disables the guard so typing is
// not blocked where the signal is unavailable.
@Test fun typeChunksWithUnknownOwnerSendsEverything() {
val sent = mutableListOf<String>()
typeChunks(listOf("aaa", "bbb"), null, { "anything" }) { sent.add(it) }
assertEquals(listOf("aaa", "bbb"), sent)
}
// parseResumedPackage must read the foreground package off any *ResumedActivity
// wording, not one OEM-specific phrasing, or the mid-type guard silently
// no-ops on ROMs that word the line differently.
@Test fun parseResumedPackageReadsEachResumedActivityWording() {
val cases = mapOf(
" topResumedActivity=ActivityRecord{8b u0 app.folio/.MainActivity t42}" to
"app.folio",
" mResumedActivity: ActivityRecord{1c u0 com.example.app/.Home t9}" to
"com.example.app",
" ResumedActivity: ActivityRecord{2d u0 app.folio/com.folio.Detail t9}" to
"app.folio",
)
for ((line, want) in cases) {
assertEquals(want, parseResumedPackage(line), line)
}
assertEquals(
null,
parseResumedPackage(" mFocusedApp=null\n no resumed line here"),
)
}
// maestroKeyFor resolves the production pressKey path: it lowercases and
// rejects an unknown key (the Maestro backend used to silently drop both
// unknown and wrong-case keys).
@Test fun maestroKeyForResolvesAndRejects() {
assertEquals(maestro.KeyCode.BACK, maestroKeyFor("back"))
assertEquals(maestro.KeyCode.BACK, maestroKeyFor("BACK"))
assertEquals(maestro.KeyCode.ENTER, maestroKeyFor("enter"))
assertFailsWith<IllegalArgumentException> { maestroKeyFor("zorp") }
}
} }
@@ -0,0 +1,53 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class RetryOpenTest {
@Test fun succeedsOnFirstAttemptWithoutSleeping() {
var opens = 0
var sleeps = 0
val result = retryOpen(4, 2000L, sleep = { sleeps++ }, log = {}) {
opens++
"driver"
}
assertEquals("driver", result)
assertEquals(1, opens)
assertEquals(0, sleeps, "a first-attempt success must not sleep")
}
@Test fun retriesUntilSuccessThenStops() {
var opens = 0
var sleeps = 0
val result = retryOpen(4, 2000L, sleep = { sleeps++ }, log = {}) {
opens++
if (opens < 3) throw RuntimeException("instrumentation host not up")
"driver"
}
assertEquals("driver", result)
assertEquals(3, opens)
assertEquals(
2,
sleeps,
"slept after attempts 1 and 2, not after the success",
)
}
// The exact failure this guards: a slow device that never opens must surface
// the last error after exhausting attempts, not loop forever or swallow it.
@Test fun rethrowsLastErrorAfterExhaustingAttempts() {
var opens = 0
var sleeps = 0
val error = assertFailsWith<IllegalStateException> {
retryOpen(3, 2000L, sleep = { sleeps++ }, log = {}) {
opens++
throw IllegalStateException("attempt $opens failed")
}
}
assertEquals("attempt 3 failed", error.message)
assertEquals(3, opens)
assertEquals(2, sleeps, "no sleep after the final failing attempt")
}
}