mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Fix action-system audit findings (#60)
* fix(replay-ui): size overlay viewBox from hierarchy root bounds
Tap points are recorded in the hierarchy's coordinate space (iOS points,
Android pixels, web CSS px) while screenshots are device pixels, so the
overlay rendered at 1/3 position on iOS 3x screens. Derive the viewBox
from the root element bounds; natural image size stays the fallback.
* fix(runner): derive trace tap point from resolveCoordinates
stampSelectorTarget preferred possibly-stale action X/Y while dispatch
preferred the fresh tree-resolved center, so the trace could record a
different point than the one tapped. Both now share resolveCoordinates.
* fix(runner): settle after InputText focus tap before key events
The focus tap raises the keyboard; with no settle the keyboard
animation races the erase/type key events on iOS, landing them in the
wrong field or dropping them. Wait for idle after a successful focus
tap, bounded by the run's idle timeout.
* fix(driver): skip pre-erase for replace-on-input drivers
The web driver's InputText already replaces content via select-all, so
the runner's unconditional EraseText was a redundant round-trip on
every InputText. A new optional TextReplacer capability lets a driver
assert replace semantics; the runner skips the erase when asserted.
* fix(hierarchy): rank spatial-fallback matches by specificity
The bounds-containment fallback returned the first pre-order match, so
a screen-sized container could win over the intended small element.
Matches are now ordered smallest-area first; equal-area matches keep
pre-order, preserving the iOS-flat equal-bounds sibling pattern.
* fix(runner): treat an unchanging transitional tree as settled
A UI persistently showing two route-level Screen ids (overlay, both
route ids alive at rest) burned the full retry budget every step and
skipped the verifier forever. A tree byte-identical to the previous
attempt now breaks the retry loop as settled; genuine cross-fades
differ between attempts and keep the retry/skip behavior.
* fix(replay-ui): skip synthetic zero-bounds root in deviceSpaceOf
The iOS hierarchy prepends a zero-bounds node before the real root
window, so elements[0] returned undefined and the overlay fell back to
the screenshot's pixel size. Take the first element with positive
extent instead; pre-order puts the root window before any content.
Verified against a real iOS trace in the replay UI.
* fix(sidecar): never replay non-idempotent actions after reconnect
A dropped connection mid-action (e.g. a read timeout while the device
is still typing) re-ran the whole block after reconnecting, typing the
text twice and double-firing taps. Non-idempotent actions now reconnect
for the next RPC's benefit but surface UNAVAILABLE, which the runner
already treats as transient; idempotent reads keep the replay.
* fix(sidecar): land the second double-tap sequentially on gesture collision
The overlapped second tap can hit the XCTest runner while the first
gesture is still executing ('only one gesture can be performed at a
time'), failing the step. The second tap now waits the first out and
retries once, keeping the tight gap on the happy path.
* fix(sidecar): map non-Exception throwables to INTERNAL status
The vendored iOS client throws failures that do not extend Exception;
runRpc missed them, killing the RPC as a channel-level Unknown the
runner cannot classify. Catch Throwable instead.
* feat(sidecar): close the driver and app under test on shutdown
* test(sidecar): cover service shutdown paths
* fix(testrun): stop the sidecar with SIGTERM before killing
* fix(sidecar): reap orphaned XCTest runner sessions at iOS init
* fix(sidecar): probe channel liveness before restarting the XCTest runner
* test(sidecar): cover WdaRecovery restart and retry policy
* fix(sidecar): absorb first-leg double-tap collision sequentially
* fix(runner): scope WDA-drop detection and cap consecutive transient failures
* chore(sidecar): silence vendored loggers on expected failure paths
* fix(runner): absorb one-off apply errors; only an unbroken streak aborts
* fix(folio): install the current build before the Android fuzz run
* chore(sidecar): silence absorbed view-hierarchy poll noise in Android runs
The driver logs an ERROR for every on-device view-hierarchy fetch that the
device-side server cancels or times out while the UI animates. The stability
poll fetches the hierarchy on a sub-second cadence and swallows those throws
to keep polling, so each line is advisory with no effect on the run. Real
failures still reach the runner as gRPC status errors, so nothing is lost.
This commit is contained in:
17 files changed
+1044
-150
No files matched your search
@@ -31,6 +31,11 @@ interface DriverBackend {
|
||||
// serialized pair from the same on-device frame. Backends may override
|
||||
// to fuse the two reads more tightly when their native API allows.
|
||||
fun snapshot(): SnapshotSample = SnapshotSample(hierarchy(), screenshot())
|
||||
|
||||
// close releases device-side resources on shutdown. The iOS backend must
|
||||
// stop its XCTest runner here: an orphaned runner session auto-restarts
|
||||
// later and hijacks the simulator's gesture daemon mid-run.
|
||||
fun close() {}
|
||||
}
|
||||
|
||||
data class SnapshotSample(
|
||||
@@ -179,6 +184,29 @@ private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode,
|
||||
out.append(')')
|
||||
}
|
||||
|
||||
// overlappedDoubleTap fires the second tap while the first is still in
|
||||
// flight, so the on-device gap stays tight on transports with high per-tap
|
||||
// latency. The overlap can collide with the other tap still executing ("only
|
||||
// one gesture can be performed at a time") on either leg; the colliding leg
|
||||
// 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() }
|
||||
Thread.sleep(40)
|
||||
try {
|
||||
tapAction()
|
||||
} catch (_: Throwable) {
|
||||
runCatching { firstTap.join() }
|
||||
tapAction()
|
||||
return
|
||||
}
|
||||
try {
|
||||
firstTap.join()
|
||||
} catch (_: Throwable) {
|
||||
tapAction()
|
||||
}
|
||||
}
|
||||
|
||||
data class MetricsSample(
|
||||
val cpuPercent: Double,
|
||||
val heapBytes: Long,
|
||||
@@ -595,6 +623,11 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
||||
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
|
||||
|
||||
override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId)
|
||||
|
||||
override fun close() {
|
||||
runCatching { driver.close() }
|
||||
runCatching { dadb.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDadb(serial: String?): dadb.Dadb {
|
||||
@@ -649,12 +682,103 @@ private fun pngHeight(bytes: ByteArray): Int {
|
||||
(bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
internal const val IOS_XCTEST_RUNNER_BUNDLE_ID = "dev.mobile.maestro-driver-iosUITests.xctrunner"
|
||||
|
||||
// reapOrphanIosRunners kills XCTest runner sessions left over from a prior
|
||||
// run. A sidecar that died without its shutdown hook leaves its xcodebuild
|
||||
// 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))
|
||||
return killed
|
||||
}
|
||||
|
||||
// WdaRecovery serializes XCTest runner recovery across concurrent RPCs. An
|
||||
// IOException on one call does not prove the runner is down (an overlapped
|
||||
// gesture can reset a single connection), and a full runner restart costs
|
||||
// around 50 seconds of downtime, so recovery probes channel liveness first
|
||||
// and only restarts a dead channel. The probe re-runs under the lock so
|
||||
// threads queued behind an in-flight restart do not restart again.
|
||||
internal class WdaRecovery(
|
||||
private val isAlive: () -> Boolean,
|
||||
private val restart: () -> Unit,
|
||||
private val log: (String) -> Unit = ::println,
|
||||
) {
|
||||
private val lock = java.util.concurrent.locks.ReentrantLock()
|
||||
|
||||
// run executes block, recovering the channel on IO failure. replay re-runs
|
||||
// the block afterwards and is only safe for idempotent reads: an action
|
||||
// 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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isIoFailure(e: Exception): Boolean =
|
||||
generateSequence(e as Throwable) { it.cause }.any { it is java.io.IOException }
|
||||
|
||||
private fun recover(cause: Exception) {
|
||||
lock.lock()
|
||||
try {
|
||||
if (isAlive()) {
|
||||
log("channel alive after $cause; skipping runner restart")
|
||||
return
|
||||
}
|
||||
log("channel dead after $cause; restarting the XCTest runner")
|
||||
val startedAt = System.currentTimeMillis()
|
||||
try {
|
||||
restart()
|
||||
} catch (restartErr: Exception) {
|
||||
throw IllegalStateException("WDA reconnect failed: $restartErr", cause)
|
||||
}
|
||||
log("XCTest runner restarted in ${System.currentTimeMillis() - startedAt} ms")
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IosDriverBackend(private val udid: String) : DriverBackend {
|
||||
private lateinit var driver: maestro.drivers.IOSDriver
|
||||
private lateinit var localDevice: ios.LocalIOSDevice
|
||||
private val reconnectLock = java.util.concurrent.locks.ReentrantLock()
|
||||
private lateinit var installer: xcuitest.installer.LocalXCTestInstaller
|
||||
private val recovery by lazy {
|
||||
WdaRecovery(
|
||||
isAlive = { runCatching { installer.isChannelAlive() }.getOrElse { false } },
|
||||
restart = { driver.open(); warmup() },
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
val reaped = reapOrphanIosRunners(udid) { command ->
|
||||
runCatching { ProcessBuilder(command).start().waitFor() }.getOrDefault(1)
|
||||
}
|
||||
if (reaped) {
|
||||
println("terminated orphaned XCTest runner session for $udid")
|
||||
// Give the killed session a beat to tear down before installing ours.
|
||||
Thread.sleep(1000)
|
||||
}
|
||||
val wdaPort = maestro.utils.SocketUtils.nextFreePort(22000, 23000)
|
||||
val tempFileHandler = maestro.utils.TempFileHandler()
|
||||
val simctlDevice = device.SimctlIOSDevice(
|
||||
@@ -667,7 +791,7 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
|
||||
context = xcuitest.installer.Context.CLI,
|
||||
snapshotKeyHonorModalViews = null,
|
||||
)
|
||||
val installer = xcuitest.installer.LocalXCTestInstaller(
|
||||
installer = xcuitest.installer.LocalXCTestInstaller(
|
||||
deviceId = udid,
|
||||
host = "127.0.0.1",
|
||||
deviceType = util.IOSDeviceType.SIMULATOR,
|
||||
@@ -714,25 +838,8 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
|
||||
warmupErr?.let { throw IllegalStateException("WDA warmup failed after 3 attempts: $it") }
|
||||
}
|
||||
|
||||
private fun <T> withReconnect(block: () -> T): T {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
val isIoFailure = generateSequence(e as Throwable) { it.cause }
|
||||
.any { it is java.io.IOException }
|
||||
if (!isIoFailure) throw e
|
||||
reconnectLock.lock()
|
||||
try {
|
||||
try { driver.open(); warmup() }
|
||||
catch (reconnectErr: Exception) {
|
||||
throw IllegalStateException("WDA reconnect failed: $reconnectErr", e)
|
||||
}
|
||||
} finally {
|
||||
reconnectLock.unlock()
|
||||
}
|
||||
block()
|
||||
}
|
||||
}
|
||||
private fun <T> withReconnect(replay: Boolean = true, block: () -> T): T =
|
||||
recovery.run(replay, block)
|
||||
|
||||
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) = withReconnect {
|
||||
runCatching { driver.stopApp(bundleId) }
|
||||
@@ -742,38 +849,33 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
|
||||
|
||||
override fun terminate(bundleId: String) = withReconnect { driver.stopApp(bundleId) }
|
||||
|
||||
override fun tap(x: Int, y: Int) = withReconnect { driver.tap(maestro.Point(x, y)) }
|
||||
override fun tap(x: Int, y: Int) = withReconnect(replay = false) { driver.tap(maestro.Point(x, y)) }
|
||||
|
||||
// The second tap request is already queued at the XCTest runner while the
|
||||
// first executes, so the on-device gap collapses to the runner's
|
||||
// turnaround instead of a full transport round trip. Sequential requests
|
||||
// leave a gap wide enough for the app to navigate between the taps.
|
||||
override fun doubleTap(x: Int, y: Int): Unit = withReconnect {
|
||||
val point = maestro.Point(x, y)
|
||||
val firstTap = java.util.concurrent.CompletableFuture.runAsync { driver.tap(point) }
|
||||
Thread.sleep(40)
|
||||
driver.tap(point)
|
||||
firstTap.join()
|
||||
Unit
|
||||
override fun doubleTap(x: Int, y: Int): Unit = withReconnect(replay = false) {
|
||||
overlappedDoubleTap { driver.tap(maestro.Point(x, y)) }
|
||||
}
|
||||
|
||||
override fun longPress(x: Int, y: Int) = withReconnect { driver.longPress(maestro.Point(x, y)) }
|
||||
override fun longPress(x: Int, y: Int) = withReconnect(replay = false) { driver.longPress(maestro.Point(x, y)) }
|
||||
|
||||
override fun tapSelector(selector: String) = withReconnect {
|
||||
override fun tapSelector(selector: String) = withReconnect(replay = false) {
|
||||
val root = driver.contentDescriptor(false)
|
||||
val bounds = findBoundsBySelector(root, selector) ?: return@withReconnect
|
||||
driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2))
|
||||
}
|
||||
|
||||
override fun inputText(text: String) = withReconnect { driver.inputText(text) }
|
||||
override fun inputText(text: String) = withReconnect(replay = false) { driver.inputText(text) }
|
||||
|
||||
override fun eraseText(characterCount: Int) = withReconnect { driver.eraseText(characterCount) }
|
||||
override fun eraseText(characterCount: Int) = withReconnect(replay = false) { driver.eraseText(characterCount) }
|
||||
|
||||
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = withReconnect {
|
||||
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = withReconnect(replay = false) {
|
||||
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L))
|
||||
}
|
||||
|
||||
override fun pressKey(key: String) = withReconnect {
|
||||
override fun pressKey(key: String) = withReconnect(replay = false) {
|
||||
StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
|
||||
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
|
||||
}
|
||||
@@ -808,6 +910,13 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
|
||||
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
|
||||
|
||||
override fun metrics(bundleId: String) = MetricsSample(0.0, 0L, 0L)
|
||||
|
||||
// close stops the XCTest runner session (kills the xcodebuild process and
|
||||
// uninstalls the runner app). Skipping this leaves an orphaned session
|
||||
// that xcodebuild later restarts, killing the next run's session.
|
||||
override fun close() {
|
||||
runCatching { driver.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
|
||||
|
||||
@@ -186,11 +186,29 @@ class DriverService(
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown runs on the JVM shutdown path (SIGTERM from the runner). It
|
||||
// terminates the app under test so the simulator is not left showing a
|
||||
// stale session, then closes the backend so the iOS XCTest runner process
|
||||
// dies with us instead of being orphaned.
|
||||
fun shutdown() {
|
||||
runCatching { launchedBundleId.getAndSet(null)?.let { backend.terminate(it) } }
|
||||
runCatching { backend.close() }
|
||||
}
|
||||
|
||||
private inline fun <T> runRpc(observer: StreamObserver<T>, block: () -> T) {
|
||||
try {
|
||||
observer.onNext(block())
|
||||
observer.onCompleted()
|
||||
} catch (cause: Exception) {
|
||||
} catch (cause: io.grpc.StatusRuntimeException) {
|
||||
// A backend that already chose a status code (e.g. UNAVAILABLE for
|
||||
// a dropped-mid-action connection) keeps it, so the runner can
|
||||
// tell transient failures from fatal ones.
|
||||
observer.onError(cause)
|
||||
} catch (cause: Throwable) {
|
||||
// Throwable, not Exception: the vendored iOS client throws
|
||||
// failures that do not extend Exception, and an uncaught one
|
||||
// kills the RPC as a channel-level Unknown instead of a status
|
||||
// the runner can classify.
|
||||
observer.onError(io.grpc.Status.INTERNAL.withDescription(cause.toString())
|
||||
.withCause(cause).asRuntimeException())
|
||||
}
|
||||
|
||||
@@ -30,11 +30,33 @@ class SidecarServer(
|
||||
|
||||
fun stop() {
|
||||
grpcServer?.shutdown()
|
||||
service.shutdown()
|
||||
shutdownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
// quietExpectedDriverNoise silences vendored loggers whose ERROR lines fire
|
||||
// on expected paths: CommandLineUtils logs every nonzero simctl exit even
|
||||
// when the caller absorbs it (terminating an app that is not running), and
|
||||
// XCTestDriverClient logs every non-2xx response including gesture
|
||||
// collisions the double-tap path retries. AndroidDriver logs an ERROR for
|
||||
// every view-hierarchy fetch the on-device server cancels or times out, which
|
||||
// happens routinely while the UI is animating; stabilitySnapshot polls the
|
||||
// hierarchy on a sub-second cadence and swallows those throws to keep polling,
|
||||
// so each absorbed failure produces a log line with no effect on the run.
|
||||
// Real failures still reach the runner as gRPC status errors, so nothing is
|
||||
// lost from run output.
|
||||
private fun quietExpectedDriverNoise() {
|
||||
org.apache.logging.log4j.core.config.Configurator.setLevel(
|
||||
"util.CommandLineUtils", org.apache.logging.log4j.Level.OFF)
|
||||
org.apache.logging.log4j.core.config.Configurator.setLevel(
|
||||
"xcuitest.XCTestDriverClient", org.apache.logging.log4j.Level.OFF)
|
||||
org.apache.logging.log4j.core.config.Configurator.setLevel(
|
||||
"maestro.drivers.AndroidDriver", org.apache.logging.log4j.Level.OFF)
|
||||
}
|
||||
|
||||
fun main(arguments: Array<String>) {
|
||||
quietExpectedDriverNoise()
|
||||
val port = arguments.indexOf("--port").let { index ->
|
||||
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1].toInt() else 0
|
||||
}
|
||||
|
||||
@@ -54,6 +54,45 @@ class DriverServiceTest {
|
||||
assertEquals(null, backend.lastBundleId)
|
||||
}
|
||||
|
||||
@Test fun shutdownTerminatesLaunchedAppAndClosesBackend() {
|
||||
var terminated: String? = null
|
||||
var closed = false
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun terminate(bundleId: String) { terminated = bundleId }
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
val serverName = InProcessServerBuilder.generateName()
|
||||
val service = DriverService(platform = "android", backend = backend)
|
||||
grpcCleanup.register(
|
||||
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start()
|
||||
)
|
||||
val channel: ManagedChannel = grpcCleanup.register(
|
||||
InProcessChannelBuilder.forName(serverName).directExecutor().build()
|
||||
)
|
||||
val client = DriverGrpc.newBlockingStub(channel)
|
||||
|
||||
client.launch(LaunchRequest.newBuilder().setBundleId("com.example").build())
|
||||
service.shutdown()
|
||||
|
||||
assertEquals("com.example", terminated)
|
||||
assertTrue(closed)
|
||||
}
|
||||
|
||||
@Test fun shutdownWithoutLaunchedAppStillClosesBackend() {
|
||||
var terminated: String? = null
|
||||
var closed = false
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun terminate(bundleId: String) { terminated = bundleId }
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
val service = DriverService(platform = "android", backend = backend)
|
||||
|
||||
service.shutdown()
|
||||
|
||||
assertEquals(null, terminated)
|
||||
assertTrue(closed)
|
||||
}
|
||||
|
||||
@Test fun tapForwardsCoordinates() {
|
||||
val backend = StubDriverBackend("android")
|
||||
val client = newClient(backend)
|
||||
@@ -83,6 +122,103 @@ class DriverServiceTest {
|
||||
assertEquals("hello world", backend.lastInputText)
|
||||
}
|
||||
|
||||
// A backend that already chose a status code (the iOS backend surfaces
|
||||
// UNAVAILABLE when the connection dropped mid-action) must keep it, so
|
||||
// the runner can tell transient failures from fatal ones.
|
||||
@Test fun backendStatusCodePassesThrough() {
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun inputText(text: String) {
|
||||
throw io.grpc.Status.UNAVAILABLE
|
||||
.withDescription("connection dropped mid-action")
|
||||
.asRuntimeException()
|
||||
}
|
||||
}
|
||||
val client = newClient(backend)
|
||||
|
||||
val thrown = kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> {
|
||||
client.inputText(Text.newBuilder().setValue("hello").build())
|
||||
}
|
||||
assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code)
|
||||
}
|
||||
|
||||
// The vendored iOS client throws failures that do not extend Exception;
|
||||
// they must still map to a status error instead of killing the RPC as a
|
||||
// channel-level Unknown the runner cannot classify.
|
||||
@Test fun nonExceptionThrowableMapsToInternal() {
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun inputText(text: String) {
|
||||
throw Throwable("only one gesture can be performed at a time")
|
||||
}
|
||||
}
|
||||
val client = newClient(backend)
|
||||
|
||||
val thrown = kotlin.test.assertFailsWith<io.grpc.StatusRuntimeException> {
|
||||
client.inputText(Text.newBuilder().setValue("hello").build())
|
||||
}
|
||||
assertEquals(io.grpc.Status.Code.INTERNAL, thrown.status.code)
|
||||
assertTrue(thrown.status.description.orEmpty().contains("only one gesture"))
|
||||
}
|
||||
|
||||
@Test fun reapOrphanIosRunnersKillsStrayXcodebuildAndRunnerApp() {
|
||||
val commands = mutableListOf<List<String>>()
|
||||
val reaped = reapOrphanIosRunners("UDID-1234") { command ->
|
||||
commands.add(command)
|
||||
0
|
||||
}
|
||||
assertTrue(reaped)
|
||||
assertEquals(2, commands.size)
|
||||
assertEquals("pkill", commands[0][0])
|
||||
assertTrue(commands[0][2].contains("test-without-building"))
|
||||
assertTrue(commands[0][2].contains("UDID-1234"))
|
||||
assertEquals(listOf("xcrun", "simctl", "terminate", "UDID-1234", IOS_XCTEST_RUNNER_BUNDLE_ID), commands[1])
|
||||
}
|
||||
|
||||
@Test fun reapOrphanIosRunnersReportsNothingFound() {
|
||||
val reaped = reapOrphanIosRunners("UDID-1234") { 1 }
|
||||
assertEquals(false, reaped)
|
||||
}
|
||||
|
||||
@Test fun overlappedDoubleTapLandsTwoTaps() {
|
||||
val invocations = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
overlappedDoubleTap { invocations.incrementAndGet() }
|
||||
assertEquals(2, invocations.get())
|
||||
}
|
||||
|
||||
@Test fun overlappedDoubleTapRetriesSequentiallyOnGestureCollision() {
|
||||
val invocations = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
val inFlight = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
// Mimic the XCTest runner: a tap issued while another gesture is
|
||||
// still executing fails instead of queuing.
|
||||
val tapAction = {
|
||||
if (!inFlight.compareAndSet(false, true)) {
|
||||
throw IllegalStateException("only one gesture can be performed at a time")
|
||||
}
|
||||
invocations.incrementAndGet()
|
||||
Thread.sleep(150)
|
||||
inFlight.set(false)
|
||||
}
|
||||
overlappedDoubleTap(tapAction)
|
||||
assertEquals(2, invocations.get())
|
||||
}
|
||||
|
||||
@Test fun overlappedDoubleTapRetriesWhenFirstLegCollides() {
|
||||
val landed = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
val failedFirst = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
// The async first tap loses the race and collides; the second tap
|
||||
// succeeds. The collision must be absorbed with a sequential retry,
|
||||
// not propagated out of the join.
|
||||
val tapAction = {
|
||||
if (failedFirst.compareAndSet(false, true)) {
|
||||
Thread.sleep(60)
|
||||
throw IllegalStateException("only one gesture can be performed at a time")
|
||||
}
|
||||
landed.incrementAndGet()
|
||||
Unit
|
||||
}
|
||||
overlappedDoubleTap(tapAction)
|
||||
assertEquals(2, landed.get())
|
||||
}
|
||||
|
||||
@Test fun doubleTapDefaultComposesTwoTaps() {
|
||||
// Interface delegation would bind the default doubleTap to the
|
||||
// delegate, bypassing the tap override, so implement the interface
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package dev.sanderling.sidecar
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WdaRecoveryTest {
|
||||
|
||||
private fun recovery(
|
||||
isAlive: () -> Boolean,
|
||||
restart: () -> Unit,
|
||||
) = WdaRecovery(isAlive = isAlive, restart = restart, log = {})
|
||||
|
||||
@Test fun aliveChannelSkipsRestartAndRetriesReads() {
|
||||
val restarts = AtomicInteger(0)
|
||||
val recovery = recovery(isAlive = { true }, restart = { restarts.incrementAndGet() })
|
||||
var calls = 0
|
||||
|
||||
val result = recovery.run(replay = true) {
|
||||
calls++
|
||||
if (calls == 1) throw IOException("connection reset")
|
||||
"ok"
|
||||
}
|
||||
|
||||
assertEquals("ok", result)
|
||||
assertEquals(2, calls)
|
||||
assertEquals(0, restarts.get())
|
||||
}
|
||||
|
||||
@Test fun aliveChannelSurfacesUnavailableForActions() {
|
||||
val restarts = AtomicInteger(0)
|
||||
val recovery = recovery(isAlive = { true }, restart = { restarts.incrementAndGet() })
|
||||
|
||||
val thrown = assertFailsWith<io.grpc.StatusRuntimeException> {
|
||||
recovery.run(replay = false) { throw IOException("connection reset") }
|
||||
}
|
||||
|
||||
assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code)
|
||||
assertEquals(0, restarts.get())
|
||||
}
|
||||
|
||||
@Test fun deadChannelRestartsOnceThenRetries() {
|
||||
val alive = AtomicBoolean(false)
|
||||
val restarts = AtomicInteger(0)
|
||||
val recovery = recovery(
|
||||
isAlive = { alive.get() },
|
||||
restart = {
|
||||
restarts.incrementAndGet()
|
||||
alive.set(true)
|
||||
},
|
||||
)
|
||||
var calls = 0
|
||||
|
||||
val result = recovery.run(replay = true) {
|
||||
calls++
|
||||
if (calls == 1) throw IOException("connection refused")
|
||||
"ok"
|
||||
}
|
||||
|
||||
assertEquals("ok", result)
|
||||
assertEquals(1, restarts.get())
|
||||
}
|
||||
|
||||
@Test fun concurrentFailuresRestartOnly() {
|
||||
val alive = AtomicBoolean(false)
|
||||
val restarts = AtomicInteger(0)
|
||||
val recovery = recovery(
|
||||
isAlive = { alive.get() },
|
||||
restart = {
|
||||
Thread.sleep(100)
|
||||
restarts.incrementAndGet()
|
||||
alive.set(true)
|
||||
},
|
||||
)
|
||||
val started = CountDownLatch(2)
|
||||
val threads = (1..2).map {
|
||||
thread {
|
||||
started.countDown()
|
||||
started.await()
|
||||
recovery.run(replay = true) {
|
||||
if (!alive.get()) throw IOException("connection refused")
|
||||
"ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
threads.forEach { it.join() }
|
||||
|
||||
assertEquals(1, restarts.get())
|
||||
}
|
||||
|
||||
@Test fun restartFailureSurfacesWdaReconnectFailed() {
|
||||
val recovery = recovery(
|
||||
isAlive = { false },
|
||||
restart = { throw IllegalStateException("xcodebuild died") },
|
||||
)
|
||||
|
||||
val thrown = assertFailsWith<IllegalStateException> {
|
||||
recovery.run(replay = true) { throw IOException("connection refused") }
|
||||
}
|
||||
|
||||
assertTrue(thrown.message.orEmpty().contains("WDA reconnect failed"))
|
||||
}
|
||||
|
||||
@Test fun nonIoFailurePropagatesWithoutRecovery() {
|
||||
val restarts = AtomicInteger(0)
|
||||
val probes = AtomicInteger(0)
|
||||
val recovery = recovery(
|
||||
isAlive = { probes.incrementAndGet() > 0 },
|
||||
restart = { restarts.incrementAndGet() },
|
||||
)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
recovery.run(replay = true) { throw IllegalArgumentException("bad selector") }
|
||||
}
|
||||
|
||||
assertEquals(0, restarts.get())
|
||||
assertEquals(0, probes.get())
|
||||
}
|
||||
|
||||
@Test fun readRetryFailureSurfacesUnavailable() {
|
||||
val recovery = recovery(isAlive = { true }, restart = {})
|
||||
|
||||
val thrown = assertFailsWith<io.grpc.StatusRuntimeException> {
|
||||
recovery.run(replay = true) { throw IOException("connection reset") }
|
||||
}
|
||||
|
||||
assertEquals(io.grpc.Status.Code.UNAVAILABLE, thrown.status.code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user