mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user