From 6b1188c5a2f3bf4960add273608c4792f8592b44 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 10:09:28 +0530 Subject: [PATCH] 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. --- .../dev/sanderling/sidecar/DriverBackend.kt | 25 ++++++++--- .../sanderling/sidecar/DriverServiceTest.kt | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 635cb02..58faa6d 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -179,6 +179,24 @@ 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 first tap still executing ("only +// one gesture can be performed at a time"); the second tap then waits the +// first out and lands sequentially 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 + } + firstTap.join() +} + data class MetricsSample( val cpuPercent: Double, val heapBytes: Long, @@ -760,12 +778,7 @@ class IosDriverBackend(private val udid: String) : DriverBackend { // 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(replay = false) { - 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 + overlappedDoubleTap { driver.tap(maestro.Point(x, y)) } } override fun longPress(x: Int, y: Int) = withReconnect(replay = false) { driver.longPress(maestro.Point(x, y)) } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt index a9a2eec..9379b78 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt @@ -102,6 +102,47 @@ class DriverServiceTest { 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 { + 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 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 doubleTapDefaultComposesTwoTaps() { // Interface delegation would bind the default doubleTap to the // delegate, bypassing the tap override, so implement the interface