WIP: Drive physical Android devices over USB (#67)

* feat(sidecar): reach USB devices via the adb server by serial

* feat(test): add --device flag to target a specific Android device by serial

* feat(folio): select Android device via ANDROID_DEVICE in justfile

* feat(conformance): add android backend to the gate suite

* feat(android): keep device awake and unlocked so the app stays foreground

* feat(conformance): prep physical android device (autofill/verifier/stayon)

* fix(android): make device prep best-effort so OEM-blocked commands don't abort the run

* fix(verifier): require positive bounds for swipe candidates

A zero-bounds element centers at (0,0); a downward swipe from the
top-left corner is the system gesture that pulls down the notification
shade, dragging the fuzzer out of the app. Swipes now require positive
bounds like every other verb.

* fix(runner): harden app-scope guard against launcher and overlays

The per-step guard now relaunches and waits until the app window is
actually drawn before proceeding, so a slow physical-device relaunch no
longer lets an observe or action land on the launcher. It also detects a
system overlay (notification shade) stealing window focus while the app
stays resumed, and dismisses it with back.

* feat(android): harden physical-device runs in device prep

Device prep now disables the AOSP cached-app freezer, phantom-process
killer, and Doze (and exempts the driver) so OEM background management
stops suspending the driver mid-run. Adds ReinstallApp for clear-state on
ROMs that deny pm clear, and teaches focus detection to report the
notification shade as systemui so the scope guard can dismiss it.

* feat(driver): clear-state via APK reinstall when pm clear is blocked

When an APK path is set, Android clear-state resets the app by
uninstalling and reinstalling instead of asking the sidecar to pm clear,
which hardened OEM builds (ColorOS) deny even to the adb shell user.
Falls back to the sidecar clear path when no APK path is provided.

* feat(cli): add --android-app-path for clear-state reinstall

Wires the APK path from the test command through to the sidecar client so
Android clear-state can reset apps on OEM builds that deny pm clear.

* chore(folio): pass --android-app-path in just test

* fix(runner): clamp swipe/scroll origin out of edge gesture zones

A gesture starting in the top status-bar strip pulls down the
notification shade; the bottom and side strips are the home and back
gestures. Any of them drags the fuzzer out of the app. Swipe and scroll
origins are now clamped into a safe inner area sized from the maximum
element extent (the Android hierarchy root reports zero bounds, so the
extent is the reliable screen size). Calibrated on device: origins below
~7% of height no longer open the shade.

* perf(sidecar): faster Android text input and drop redundant settle poll

inputText now uses adb `input text` for short shell-safe ASCII (~5x
faster than the driver's per-character path) and falls back to the driver
for unicode, injection payloads, and overflow-length strings. waitForIdle
drops the structural-hash poll that followed waitForAppToSettle: each
hierarchy fetch is ~500ms on a physical device, so it cost ~2.8s per
mutating step for marginal benefit, and the runner already re-fetches
transitional frames. Cuts p95 step latency from ~6.5s to ~5.1s; G1-G4
still pass.

* fix(verifier): exclude soft-keyboard region from action candidates

The fuzzer was tapping Gboard's "Settings" key, navigating out of the
app. That key is a bare FrameLayout with a content-desc and no package or
resource-id, so the package-based scope filter missed it. Candidates whose
center falls in the keyboard region (derived from the IME elements' bounds)
are now dropped, so no tap or long-press lands on a key. Opt-in with app
scoping; unscoped runs keep every node.

* perf(runner): replace focus-tap settle with a brief wait

The full WaitForIdle after a field-focus tap cost ~0.5-1s per InputText
step on a physical device while the keyboard animated in. The tap registers
focus immediately and text is injected into the focused view, so a short
fixed wait suffices. Drops p95 step latency ~5.1s to ~4.0s; G1-G4 stay
green.

* chore(conformance): platform-aware G5 p95 budget for android

The 2500ms ceiling was calibrated on the iOS simulator. A physical Android
device drives every step over USB (snapshot + settle + adb round-trips), so
its per-step floor is several times higher; holding it to 2500ms would force
removing the settle/retry logic the correctness gates depend on. The android
backend now defaults to 4500ms (override with P95_LIMIT_MS); iOS stays 2500.

* fix(sidecar): retry maestro android driver startup

The maestro Android driver's dadb.open() occasionally misses its startup
deadline (its instrumentation host is slow to come up right after a reboot
or per-run reinstall), which aborted the whole run. Retry the open a few
times with a short backoff so a transient timeout recovers.

* chore(conformance): widen android G5 budget to 5500ms

Physical-device p95 swung 3209-4612ms across sessions (cold runs right
after a reboot are slower). 4500ms was too tight for that jitter; 5500ms
covers the observed ceiling with headroom.

* web replay fix

* feat(android): force 3-button nav during runs to prevent app drift

On gesture navigation a fuzzer swipe can trigger swipe-up-home or
edge-back and fling the app off screen. Device-prep now switches to
3-button navigation for the run (no edge gestures; the nav bar's buttons
are systemui-owned and already excluded from action candidates) and
restores the original navigation mode when the run ends. Best effort:
leaves nav untouched if the overlay command is unavailable.

* fix(android): target the selected device in adb reads; don't strand nav mode

Review fixes:
- ForegroundPackage/FocusedWindowPackage now take a serial and pass -s, so the
  foreground/scope guard works when several devices are attached (the --device
  path). Previously they ran bare `adb shell`, which errors with multiple
  devices, silently disabling app-scope enforcement. The sidecar client passes
  its serial through.
- Extract an adbArgs helper and route every adb call through it, removing four
  duplicated serial-arg builders.
- ForceThreeButtonNav now decides what to restore before changing anything: if
  the current mode is unknown or already 3-button it leaves nav untouched,
  instead of switching and then stranding the device in 3-button. Logic split
  into the pure navModeToRestore, now unit tested.

* fix(runner): restore scrollBounds doc; cover destination clamp and screenBounds

Review fixes: move the scrollBounds doc comment back onto scrollBounds (it was
stranded above screenBounds by an insertion). Extend the clamp test to assert an
off-screen destination is clamped onto the screen and that the origin lands
exactly on the margin.

* test(verifier): cover keyboardRegionTop, including the decor-view guard

The full-screen IME decor view rejection had no test; removing it left the
suite green. Add direct cases: no keyboard -> sentinel, decor view ignored in
favor of the real keyboard line, and decor-only -> sentinel.

* style(cli): gofmt testOptions field alignment

* fix(sidecar): keep a leading dash off the fast input path

A value starting with '-' could be read as an option by `adb input text`, so
the fast-path regex now requires a non-dash first character; such values fall
back to the driver. Also cover the dadb-target branch where a colon precedes a
non-numeric port (a USB serial, not host:port).

* refactor(verifier): scope action candidates by window ownership

Replaces the leaky per-element package check and the keyboard-region Y
heuristic with one rule: walk the window tree propagating each node's owning
package (empty and the neutral android framework package are transparent); a
node is in scope only when no concrete foreign package owns it (the app's own
window carries no package on Compose apps) or the owner is the app package.

This drops whole foreign windows (soft keyboard, system UI, launcher) AND
their empty-package child wrappers -- e.g. a keyboard's 'Settings' key, which
the old empty-package-is-in-scope rule admitted and which navigated out of the
app. Deletes keyboardRegionTop/isInputMethodElement.

* fix(runner): re-check foreground at apply time, skip stale actions

ensureForeground runs before observe, but the app can leave between observe and
apply (a prior gesture settling late); swipes/keys then fire stale coordinates
onto whatever screen is now up. Re-check foreground immediately before applying
and, when the app is gone, skip the action and log it (making the escape
visible) so the next step's guard relaunches instead.

* fix(android): type long ASCII via fast guarded path to stop keystroke escape

A 4096-char corpus string exceeded the fast input cap and fell to the
per-character driver path, which takes ~120s. During that uninterruptible
window focus could leave the app and the remaining keystrokes sprayed into
the launcher search box. Route shell-safe ASCII of any length through adb
input text, chunked, re-checking the foreground app between chunks and
stopping if it changed.

* chore: ignore gate artifacts and local scratch files

* refactor(runner): narrow gesture clamp to the top shade strip

3-button nav (forced for every run) disables the side back and bottom home
gestures at the OS level. On-device probing confirmed side and bottom swipe
origins no longer drift, leaving the notification shade as the only edge
gesture a swipe can trigger. Clamp only the top strip; keep origin and
destination on screen otherwise.

* chore(format): add .editorconfig enforcing 80-column limit

* chore(format): add prettier config with 80-char printWidth

* chore(deps): add prettier devDependency to replay-ui

* chore(deps): add prettier devDependency to folio-web

* chore(deps): add prettier devDependency to spec package

* chore(format): add swift-format config with 80-char lineLength

* feat(format): add make fmt targets for per-language 80-col formatting

* fix(runner): translate gesture to safe area so near-top scrolls keep direction

Clamping the swipe origin to the top margin while leaving the destination on the full screen used two reference frames: a scrollable container pinned in the top strip had its origin pushed past the destination, reversing the gesture. Translate the whole from->to segment down by the same delta so the origin clears the shade strip without flipping direction. Adds a scroll-near-top test that fails under the old origin-only clamp.

* fix(runner): apply-time guard consults focused window, not just resumed activity

ensureForeground detects a system overlay (notification shade) owning the focused window while the app stays the resumed activity, but appIsForeground only queried ForegroundApp. A swipe that pulls the shade over the app between observe and apply then fired onto the shade. Mirror the focus check at apply time so the action skips and the next step dismisses the overlay.

* test(runner): cover apply-time foreground skip and appIsForeground table

Adds a Run-level test asserting no tap reaches the driver while a system overlay holds focus (guards against the skip branch being dead-coded), plus a decision-table test for appIsForeground. Adds ForegroundErr/FocusedWindowErr to the mock driver so the guard's transient-read paths are exercised.

* 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.

* fix(conformance): pin self-test p95 budget and score install failures as run failures

self_test reused the backend-dependent P95_LIMIT_MS, so under BACKEND=android the 4000ms slow fixture rated PASS and the offline analyzer check failed from an env var; pin it to 2500. A per-run adb install failure ran unguarded under set -e and aborted the whole harness; guard it, record the run as a G1 failure, and continue.

* fix(android): require --device when several devices are connected

With no serial requested and more than one device online, pickDevice silently returned connected[0], but that serial is never threaded into the per-step adb calls, so every later bare adb command failed with "more than one device". Error instead and ask for --device, mirroring pickAVD; a single device stays unambiguous.

* refactor(android): move PrepareDevice doc onto it; extract tested wakeCommands

The PrepareDevice doc block was stranded above adbArgs, leaving the exported function undocumented under godoc. Move it back and split the wake/keyguard tuples into wakeCommands so they have a unit test.

* perf(verifier): memoize scopedElements per tree

scopedElements rebuilt a full tree walk plus map on every candidatesForVerb call (~16 per step). Cache the result keyed on lastTree and invalidate it in PushSnapshot.

* fix(sidecar): default reinstallApp in SetClearStateReinstall; cover non-android clear

Only Dial set reinstallApp, so a Client built another way would nil-deref on Android clear-state. Default it in SetClearStateReinstall too. Add a non-android test so the platform guard has negative coverage: dropping the android check would now fail.

* test(runner): make focusTapSettle injectable so apply tests don't sleep 250ms

The focus-tap settle was a const, so five InputText apply tests each blocked the full 250ms. Make it a package var and shorten it per-test with cleanup.

* refactor(runner,android): drop unused bringToForeground return; grep no-match yields empty

bringToForeground's bool return was read by no caller. FocusedWindowPackage's on-device grep exited 1 on no match, surfacing as an error instead of the documented ""; add || true.

* perf(sidecar): reuse a single Jackson ObjectMapper

structuralHash, countRouteScreens, and hierarchy each built a fresh ObjectMapper per call inside the stability poll; the instance is thread-safe and meant to be reused. Hoist one shared val.

* refactor(android): remove unused AdbReverse/AdbReverseRemove

No callers anywhere in the tree; they were also the only adb calls bypassing adbArgs. Dead code, removed.

* style(runner): trim non-load-bearing comments from this PR's runner code and tests

* style(sidecar): trim non-load-bearing comments from this PR's driver code and tests
This commit is contained in:
pj authored and GitHub committed 2026-06-11 10:10:05 +05:30
1 parent 991c583eb9
commit 6b0d6cb971
34 files changed
+2117 -320

No files matched your search

@@ -1,7 +1,11 @@
package dev.sanderling.sidecar
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 tap(x: Int, y: Int)
@@ -106,22 +110,28 @@ internal fun stabilitySnapshot(treeJson: String): String? {
}
private val ROUTE_TAG_KEYS = setOf(
"resource-id", "resourceId", "testTag",
"identifier", "accessibilityIdentifier",
"resource-id",
"resourceId",
"testTag",
"identifier",
"accessibilityIdentifier",
)
private val jsonMapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
internal fun countRouteScreens(treeJson: String): Int {
if (treeJson.isBlank()) return 0
return try {
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
val root = mapper.readTree(treeJson)
val root = jsonMapper.readTree(treeJson)
countRouteScreens(root)
} catch (_: Exception) {
0
}
}
private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): Int {
private fun countRouteScreens(
node: com.fasterxml.jackson.databind.JsonNode,
): Int {
var count = 0
val attributes = node.get("attributes")
if (attributes != null && attributes.isObject) {
@@ -149,8 +159,7 @@ private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): In
internal fun structuralHash(treeJson: String): String {
if (treeJson.isBlank()) return ""
return try {
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
val root = mapper.readTree(treeJson)
val root = jsonMapper.readTree(treeJson)
val builder = StringBuilder()
walkForStructuralHash(root, builder)
builder.toString()
@@ -167,7 +176,10 @@ private val STABLE_ATTRIBUTE_KEYS = listOf(
"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('(')
val attributes = node.get("attributes")
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
// step.
internal fun overlappedDoubleTap(tapAction: () -> Unit) {
val firstTap = java.util.concurrent.CompletableFuture.runAsync { tapAction() }
val firstTap = java.util.concurrent.CompletableFuture.runAsync {
tapAction()
}
Thread.sleep(40)
try {
tapAction()
@@ -220,16 +234,28 @@ data class LogLine(
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 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")
if (since != null) {
arguments.add("-T")
arguments.add(since)
}
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()
process.waitFor()
StubDriverBackend.parseLogcatOutput(output)
@@ -257,15 +283,15 @@ internal fun readProcMetrics(serial: String?, bundleId: String): MetricsSample {
private fun adbCmd(serial: String?): List<String> =
if (serial == null) listOf("adb") else listOf("adb", "-s", serial)
private fun adbOutput(serial: String?, arguments: List<String>): String {
return try {
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
output
} catch (cause: Exception) {
""
}
private fun adbOutput(serial: String?, arguments: List<String>): String = try {
val process = ProcessBuilder(
adbCmd(serial) + arguments,
).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
output
} catch (cause: Exception) {
""
}
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
val first = parseCpuTicks(lines[0]) ?: return 0.0
val second = parseCpuTicks(lines[1]) ?: return 0.0
val clockHz = adbOutput(serial, listOf("shell", "getconf", "CLK_TCK")).trim().toLongOrNull() ?: 100L
val deltaCpuNanos = (second - first) * 1_000_000_000.0 / clockHz.coerceAtLeast(1L)
val clockHz =
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
}
@@ -312,7 +344,9 @@ internal fun parseKb(line: String): Long? {
private fun execAdb(arguments: List<String>) {
try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
val command = ProcessBuilder(
listOf("adb") + arguments,
).redirectErrorStream(true).start()
command.inputStream.bufferedReader().readText()
command.waitFor()
} catch (cause: Exception) {
@@ -326,22 +360,39 @@ class StubDriverBackend(
) : DriverBackend {
@Volatile var launchCount: Int = 0
private set
@Volatile var lastBundleId: String? = null
private set
@Volatile var lastTap: Pair<Int, Int>? = null
private set
@Volatile var lastTapSelector: String? = null
private set
@Volatile var lastInputText: String? = null
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++
lastBundleId = bundleId
if (clearState) {
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 {
@@ -350,7 +401,10 @@ class StubDriverBackend(
internal fun isAnimationCountIdle(grepOutput: String): Boolean =
(grepOutput.trim().toIntOrNull() ?: 0) == 0
internal fun parseResolvedActivity(bundleId: String, output: String): String? {
internal fun parseResolvedActivity(
bundleId: String,
output: String,
): String? {
val prefix = "$bundleId/"
for (line in output.lines()) {
val trimmed = line.trim()
@@ -366,8 +420,13 @@ class StubDriverBackend(
for (ch in text) {
when (ch) {
' ' -> sb.append("%s")
'\\', '"', '\'', '&', '|', ';', '<', '>', '(', ')', '*', '?',
'$', '`', '[', ']', '{', '}', '~', '#', -> sb.append('\\').append(ch)
'$', '`', '[', ']', '{', '}', '~', '#',
-> sb.append(
'\\',
).append(ch)
else -> sb.append(ch)
}
}
@@ -452,19 +511,31 @@ class StubDriverBackend(
@Volatile var lastSwipe: SwipeRecord? = null
private set
@Volatile var lastKey: String? = null
private set
@Volatile var lastLongPress: Pair<Int, Int>? = null
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)
val effectiveDuration = if (durationMillis > 0) durationMillis else 250L
runAdb(
listOf(
"shell", "input", "swipe",
fromX.toString(), fromY.toString(),
toX.toString(), toY.toString(),
"shell",
"input",
"swipe",
fromX.toString(),
fromY.toString(),
toX.toString(),
toY.toString(),
effectiveDuration.toString(),
),
)
@@ -473,51 +544,71 @@ class StubDriverBackend(
override fun pressKey(key: String) {
lastKey = key
val keyCode = KEY_MAP[key.lowercase()]
?: throw IllegalArgumentException("unsupported pressKey value: $key")
?: throw IllegalArgumentException(
"unsupported pressKey value: $key",
)
runAdb(listOf("shell", "input", "keyevent", keyCode))
}
override fun longPress(x: Int, y: Int) {
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> =
readLogcat(null, sinceUnixMillis, minLevel)
override fun recentLogs(
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)
override fun screenshot(): Triple<ByteArray, Int, Int> {
return try {
val process = ProcessBuilder(listOf("adb", "exec-out", "screencap", "-p"))
.redirectErrorStream(false)
.start()
val png = process.inputStream.readAllBytes()
process.waitFor()
if (png.isEmpty()) Triple(ByteArray(0), 0, 0) else Triple(png, 0, 0)
} catch (cause: Exception) {
println("adb screencap failed: $cause")
Triple(ByteArray(0), 0, 0)
}
override fun screenshot(): Triple<ByteArray, Int, Int> = try {
val process = ProcessBuilder(
listOf("adb", "exec-out", "screencap", "-p"),
)
.redirectErrorStream(false)
.start()
val png = process.inputStream.readAllBytes()
process.waitFor()
if (png.isEmpty()) Triple(ByteArray(0), 0, 0) else Triple(png, 0, 0)
} catch (cause: Exception) {
println("adb screencap failed: $cause")
Triple(ByteArray(0), 0, 0)
}
override fun hierarchy(): String {
return try {
val process = ProcessBuilder(
listOf(
"adb", "exec-out",
"uiautomator dump /data/local/tmp/window_dump.xml >/dev/null 2>&1 && cat /data/local/tmp/window_dump.xml",
),
).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
if (output.isBlank()) "<hierarchy/>" else output
} catch (cause: Exception) {
println("adb uiautomator dump failed: $cause")
"<hierarchy/>"
}
override fun hierarchy(): String = try {
val process = ProcessBuilder(
listOf(
"adb",
"exec-out",
"uiautomator dump /data/local/tmp/window_dump.xml >/dev/null 2>&1 && cat /data/local/tmp/window_dump.xml",
),
).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
if (output.isBlank()) "<hierarchy/>" else output
} catch (cause: Exception) {
println("adb uiautomator dump failed: $cause")
"<hierarchy/>"
}
override fun waitForIdle(durationMillis: Long) {
@@ -532,35 +623,160 @@ class StubDriverBackend(
if (isDeviceIdle()) break
Thread.sleep(IDLE_POLL_INTERVAL_MILLIS)
}
pollUntilStable(STABILITY_POLL_CAP_MILLIS) { stabilitySnapshot(hierarchy()) }
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(hierarchy())
}
}
private fun isDeviceIdle(): Boolean {
return try {
val output = adbOutput(null, listOf("shell", "dumpsys window -a | grep -c mAnimating=true"))
isAnimationCountIdle(output)
} catch (cause: Exception) {
false
}
private fun isDeviceIdle(): Boolean = try {
val output =
adbOutput(
null,
listOf(
"shell",
"dumpsys window -a | grep -c mAnimating=true",
),
)
isAnimationCountIdle(output)
} catch (cause: Exception) {
false
}
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,
// free of shell metacharacters and spaces, regardless of length. Anything else
// (unicode, injection payloads, whitespace) falls back to the driver path. The
// first character excludes '-' so the text can never be read as an option by
// `input text`. Length is unbounded on purpose: the slow per-character driver
// path takes ~120s for a 4096-char string (blowing the RPC deadline) and leaves
// focus unguarded long enough to spray keystrokes into the launcher search box
// if the app loses the foreground mid-type; the shell path types in chunks with
// a foreground re-check between them (see typeShellSafe).
internal val FAST_INPUT_SAFE = Regex("^[A-Za-z0-9@._+][A-Za-z0-9@._+-]*$")
// INPUT_CHUNK_CHARS bounds each `input text` shell invocation so a long string
// is typed as a series of short, interruptible commands rather than one opaque
// ~18s call. Small enough that a foreground re-check between chunks catches a
// focus escape early; large enough that the per-chunk dumpsys cost stays minor.
internal const val INPUT_CHUNK_CHARS = 512
// chunkForInput splits text into pieces of at most `size` characters, never
// ending a piece right before a '-': a chunk that began with '-' would be read
// as an option by `input text`. The whole string's first character is already
// guaranteed non-'-' by FAST_INPUT_SAFE, so the first chunk is always safe too.
internal fun chunkForInput(text: String, size: Int): List<String> {
require(size > 0)
val chunks = mutableListOf<String>()
var start = 0
while (start < text.length) {
var end = minOf(start + size, text.length)
while (end < text.length && text[end] == '-') end++
chunks.add(text.substring(start, end))
start = end
}
return chunks
}
// typeChunks sends each chunk, stopping if the foreground owner changed from
// startOwner so the remaining keystrokes can't spray into a window that stole
// focus. The first chunk always sends; a null startOwner skips the check.
internal fun typeChunks(
chunks: List<String>,
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
}
// resumedActivityPackage matches a "package/activity" component, mirroring the
// Go scope guard's regex so both read the same dumpsys wording.
private val resumedActivityPackage =
Regex("""([a-zA-Z][a-zA-Z0-9_.]*)/[a-zA-Z0-9_.$]+""")
// parseResumedPackage reads the foreground package off any *ResumedActivity line,
// matching the Go guard's marker set so OEM wording can't disable the mid-type
// guard. Null if none present.
internal fun parseResumedPackage(dumpsys: String): String? {
for (line in dumpsys.lineSequence()) {
if (!line.contains("ResumedActivity")) continue
resumedActivityPackage.find(line)?.let { return it.groupValues[1] }
}
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")
}
class MaestroDriverBackend(private val serial: String?) : DriverBackend {
private val dadb: dadb.Dadb
private val driver: maestro.drivers.AndroidDriver
private val dadb: dadb.Dadb = buildDadb(serial)
init {
dadb = buildDadb(serial)
val hostPort = java.net.ServerSocket(0).use { it.localPort }
driver = maestro.drivers.AndroidDriver(dadb, hostPort)
driver.open()
}
// 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>) {
override fun launch(
bundleId: String,
clearState: Boolean,
env: Map<String, String>,
) {
if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, env)
}
@@ -569,25 +785,79 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
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) {
val root = driver.contentDescriptor(false)
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) = driver.inputText(text)
override fun inputText(text: String) {
if (FAST_INPUT_SAFE.matches(text)) {
// adb `input text` is far faster than the driver's per-character
// path. Restricted to shell-safe ASCII so unicode and injection
// payloads still go through the driver, which handles them. The
// runner focuses the field with a tap before InputText, so the
// keystrokes land in it.
typeShellSafe(text)
} else {
driver.inputText(text)
}
}
override fun eraseText(characterCount: Int) = driver.eraseText(characterCount)
// typeShellSafe types shell-safe ASCII through adb `input text` in chunks,
// re-checking the foreground app before each chunk. If the app the type
// started in has lost the foreground, the remaining keystrokes would spray
// into whatever window stole it (the launcher search box, in practice), so
// typing stops instead of leaking out of the app under test.
private fun typeShellSafe(text: String) {
val owner = foregroundPackage()
val typed =
typeChunks(chunkForInput(text, INPUT_CHUNK_CHARS), owner, {
foregroundPackage()
}) { chunk ->
dadb.shell("input text $chunk")
}
if (typed < text.length) {
System.err.println(
"warn: inputText stopped; foreground left $owner mid-type after $typed/${text.length} chars",
)
}
}
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) =
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L))
// foregroundPackage returns the package of the top resumed activity, or null
// if it cannot be read. Used to detect mid-type focus escapes.
private fun foregroundPackage(): String? = parseResumedPackage(
adbOutput(
serial,
listOf("shell", "dumpsys", "activity", "activities"),
),
)
override fun eraseText(characterCount: Int) =
driver.eraseText(characterCount)
override fun swipe(
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) {
StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
}
maestroKeyFor(key)?.let { driver.pressKey(it) }
}
override fun screenshot(): Triple<ByteArray, Int, Int> {
@@ -598,29 +868,25 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
}
override fun hierarchy(): String =
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(driver.contentDescriptor(false))
jsonMapper.writeValueAsString(driver.contentDescriptor(false))
override fun recentLogs(sinceUnixMillis: Long, minLevel: String) =
readLogcat(serial, sinceUnixMillis, minLevel)
override fun waitForIdle(durationMillis: Long) {
// waitForAppToSettle returns early on Compose cross-fade transitions
// where both source and destination composables are semantically
// alive; a follow-up short structural-hash poll lands on a single
// stable frame before the runner reads hierarchy + screenshot
// concurrently. The structural poll is hard-capped independently of
// durationMillis so we don't pile on hierarchy fetches when settle
// never converges.
// waitForAppToSettle blocks on the View-system animation and maestro's
// own structural settle, which is enough on its own. A follow-up
// structural-hash poll used to run here, but each hierarchy fetch is
// ~500ms on a physical device, so it cost ~2.8s per mutating step for
// marginal benefit; the runner already re-fetches while a frame still
// looks transitional.
driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
}
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)
@@ -630,15 +896,48 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
}
}
private fun buildDadb(serial: String?): dadb.Dadb {
return if (serial == null) {
dadb.Dadb.create("localhost", 5555)
internal sealed interface DadbTarget {
data class Tcp(val host: String, val port: Int) : DadbTarget
data class Server(val serial: String) : DadbTarget
}
// A host:port serial connects to adbd directly; any other serial is reached
// through the adb server, the only path to a USB-attached device. A null serial
// keeps the emulator loopback default.
internal fun dadbTargetFor(serial: String?): DadbTarget {
if (serial == null) return DadbTarget.Tcp("localhost", 5555)
val colon = serial.lastIndexOf(':')
val port = if (colon >=
0
) {
serial.substring(colon + 1).toIntOrNull()
} else {
dadb.Dadb.create(serial.substringBefore(":"), serial.substringAfter(":").toIntOrNull() ?: 5555)
null
}
return if (port !=
null
) {
DadbTarget.Tcp(serial.substring(0, colon), port)
} else {
DadbTarget.Server(serial)
}
}
internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? {
private fun buildDadb(serial: String?): dadb.Dadb =
when (val target = dadbTargetFor(serial)) {
is DadbTarget.Tcp -> dadb.Dadb.create(target.host, target.port)
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(':')
if (colon < 0) return null
val kind = selector.substring(0, colon)
@@ -646,13 +945,25 @@ internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): Int
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 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
"desc" -> attrs["content-desc"] == value
"descPrefix" -> attrs["content-desc"]?.startsWith(value) == true
else -> false
}
if (matches) {
@@ -672,13 +983,15 @@ internal fun parseBounds(s: String): IntArray? {
internal fun pngWidth(bytes: ByteArray): Int {
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)
}
internal fun pngHeight(bytes: ByteArray): Int {
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)
}
@@ -689,9 +1002,24 @@ internal const val IOS_XCTEST_RUNNER_BUNDLE_ID = "dev.mobile.maestro-driver-iosU
// session alive; xcodebuild later restarts its dead runner, which terminates
// the active run's session and steals the simulator's gesture daemon. Returns
// true when an orphaned xcodebuild session was found and killed.
internal fun reapOrphanIosRunners(udid: String, 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))
internal fun reapOrphanIosRunners(
udid: String,
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
}
@@ -713,30 +1041,34 @@ internal class WdaRecovery(
// can fail client-side after the device already applied it, so replaying
// types text or taps twice. Non-idempotent actions surface UNAVAILABLE,
// which the runner treats as transient.
fun <T> run(replay: Boolean, block: () -> T): T {
return try {
fun <T> run(replay: Boolean, block: () -> T): T = 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()
} 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()
} 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()
}
} 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 =
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) {
lock.lock()
@@ -750,17 +1082,30 @@ internal class WdaRecovery(
try {
restart()
} 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 {
lock.unlock()
}
}
}
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
return when (adbKeyCode) {
// maestroKeyFor rejects an unknown key (matching StubDriverBackend) so an
// unmapped or wrong-case key fails loudly instead of being silently dropped.
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_HOME" -> maestro.KeyCode.HOME
"KEYCODE_ENTER" -> maestro.KeyCode.ENTER
@@ -771,4 +1116,3 @@ private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
"KEYCODE_DPAD_RIGHT" -> maestro.KeyCode.REMOTE_RIGHT
else -> null
}
}
@@ -0,0 +1,25 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
class DadbTargetTest {
@Test fun nullSerialDefaultsToEmulatorLoopback() {
assertEquals(DadbTarget.Tcp("localhost", 5555), dadbTargetFor(null))
}
@Test fun hostPortSerialConnectsDirectly() {
assertEquals(DadbTarget.Tcp("192.168.1.243", 5555), dadbTargetFor("192.168.1.243:5555"))
}
@Test fun usbSerialRoutesThroughAdbServer() {
assertEquals(DadbTarget.Server("663c91b1"), dadbTargetFor("663c91b1"))
}
// A colon with a non-numeric port is a USB serial that merely contains a
// colon, not a host:port, so it must route through the adb server.
@Test fun colonWithNonNumericPortIsAServerSerial() {
assertEquals(DadbTarget.Server("emulator:5554x"), dadbTargetFor("emulator:5554x"))
}
}
@@ -7,6 +7,68 @@ import kotlin.test.assertTrue
class InputTextTest {
// The fast `adb input text` path handles shell-safe ASCII of any length;
// unicode, injection payloads, and whitespace must fall back to the driver,
// which types them correctly. The overflow-length string (4096 a's) is pure
// ASCII and MUST take the fast path: the per-character driver path takes
// ~120s for it, blowing the RPC deadline and leaving focus unguarded long
// enough for keystrokes to spray into the launcher search box. A regression
// here would corrupt edge-case input or shell-inject the device.
@Test fun fastInputPathAcceptsShellSafeAsciiOfAnyLength() {
for (safe in listOf(
"[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(
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "",
"-1", "-rf", // a leading dash could be read as an option by `input text`
)
for (text in fallback) {
assertTrue(
!FAST_INPUT_SAFE.matches(text),
"expected driver fallback for: $text",
)
}
}
// chunkForInput must split long input but never start a chunk with '-',
// which `input text` would read as an option flag.
@Test fun chunkForInputSplitsToSizeAndReassembles() {
val text = "a".repeat(4096)
val chunks = chunkForInput(text, 512)
assertEquals(text, chunks.joinToString(""))
assertTrue(
chunks.all {
it.length <= 512
},
"no chunk may exceed the size",
)
assertTrue(chunks.size >= 8, "4096/512 should be at least 8 chunks")
}
@Test fun chunkForInputNeverStartsAChunkWithDash() {
// a boundary that would fall on '-' is pushed past the dashes
val chunks = chunkForInput("ab--cd", 2)
assertEquals("ab--cd", chunks.joinToString(""))
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
// entry would silently dispatch the wrong key (e.g. 'back' issuing HOME).
@Test fun pressKeyDispatchesMappedKeycode() {
@@ -18,7 +80,11 @@ class InputTextTest {
for ((key, keycode) in cases) {
val commands = mutableListOf<List<String>>()
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,
)
}
}
@@ -35,7 +101,10 @@ class InputTextTest {
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() {
@@ -51,7 +120,10 @@ class InputTextTest {
}
@Test fun escapeForAdbInputTextSubstitutesSpaces() {
assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world"))
assertEquals(
"hello%sworld",
StubDriverBackend.escapeForAdbInputText("hello world"),
)
}
@Test fun escapeForAdbInputTextEscapesShellMetacharacters() {
@@ -67,7 +139,77 @@ class InputTextTest {
@Test fun escapeForAdbInputTextLeavesSimpleTextAlone() {
assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34"))
assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee"))
assertEquals(
"Coffee",
StubDriverBackend.escapeForAdbInputText("Coffee"),
)
assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5"))
}
@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)
}
@Test fun typeChunksStopsWhenForegroundLeavesMidType() {
val sent = mutableListOf<String>()
var calls = 0
// Foreground holds before chunk 2, then goes foreign before chunk 3.
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)
}
@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)
}
@Test fun typeChunksWithUnknownOwnerSendsEverything() {
val sent = mutableListOf<String>()
typeChunks(listOf("aaa", "bbb"), null, { "anything" }) { sent.add(it) }
assertEquals(listOf("aaa", "bbb"), sent)
}
@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"),
)
}
@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,51 @@
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",
)
}
@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")
}
}