fix: WDA startup flakiness - warmup + connection drop message (#40)

* feat(ios): add simulator management package

* feat(testrun): add iOS platform path (simctl launch + direct TCP)

* feat(cli): add --ios-device flag and IosDevice option

* feat(sdk-ios): add Kotlin Native iOS SDK (TCP agent + POSIX socket + dispatch pauser)

* feat(folio-ios): wire SanderlingIos.start() in MainViewController

* feat(folio-ios): add test-ios justfile recipe

* fix(sdk-ios): remove unavailable C macros; manual byte swap + no-cast warnings

* fix(testrun): simctl-first launch order for iOS; Maestro init after SDK connects

* feat(proto): add env map to LaunchRequest

* feat(driver): add env param to Launch interface + all implementations

* feat(testrun): launch iOS app via XCTest with env vars instead of simctl

* feat(sidecar): add IosDriverBackend using Maestro IOSDriver + env pass-through

* feat(sidecar): wire env map in DriverService + IosDriverBackend in Main

* fix(sidecar): use LocalIOSDevice (WDA+simctl) + stop before relaunch

* fix(sidecar): include exception type in gRPC error description

* fix(sidecar): pick free WDA port instead of hardcoded 9100

Use SocketUtils.nextFreePort to pick a free port in the 22000-23000 range
rather than hardcoding 9100, which only worked if a previous WDA session
left a listener there.

* fix(sdk-ios): check semaphore wait result and throw on snapshot timeout

dispatch_semaphore_wait returns nonzero on timeout; ignoring the return
value caused pauseAndSnapshot to silently return an empty map, sending a
garbage empty STATE frame to the host. Now throws so the agent loop
reconnects instead.

* fix(folio-ios): register snapshot extractors before starting agent

SanderlingIos.start() was called before the snapshot objects were
initialized, so a PAUSE message arriving early produced an empty snapshot.
Move start() to after all extractors are registered.

* chore(ios): remove dead LaunchApp function

LaunchApp had no callers since bff3a49 switched iOS launch to go through
the sidecar driver. Remove the dead code and unused os import.

* test(ios): add unit tests for pickSimulator and iOS flag parsing

Tests for all pickSimulator branches (by name, by UDID, unknown, empty
list, iPhone preference, fallback to first). Also tests BootedUDID on a
canceled context and verifies --platform ios and --ios-device flags are
accepted by parseTestArgs.

* fix(ios): propagate error from BootedUDID instead of silently swallowing

* fix(sidecar): IosDriverBackend.healthy() returns true; WDA liveness checked in open()

* fix(sidecar): warm up WDA after health check to absorb startup race

* fix(runner): surface clear message on WDA connection drop

* docs(testrun): note WDA warmup location above WaitForHealth

* fix(sidecar): extract warmup + add one-shot WDA reconnect on IOException

* fix(runner): fatal on permanent WDA drop during hierarchy fetch

* fix(sidecar): walk cause chain in withReconnect to catch Maestro-wrapped IOException

* fix(sidecar): explicit Unit return in pressKey and waitForIdle withReconnect lambdas

* fix(sidecar): serialize WDA reconnect with ReentrantLock to prevent concurrent xcodebuild races

* fix web examples package config

* Revert "fix web examples package config"

This reverts commit 70c10ade27.

* chore: gitignore built sanderling binary

* fix(hierarchy): parse iOS [x1,y1][x2,y2] bounds + match iOS merged desc labels

* refactor(folio-spec): replace bloated spec with two focused properties

Login is opportunistic. Two concrete properties:
1. every new account starts with balance 0
2. every new txn changes ledger balance by exactly its signed amount

Actions: directed login -> addAccount -> addTxn -> back weighted flow.
This commit is contained in:
pj authored and GitHub committed 2026-04-25 17:39:39 +07:00
1 parent 97154cf580
commit 2a1b263b8c
8 files changed
+224 -322

No files matched your search

@@ -487,7 +487,8 @@ private fun pngHeight(bytes: ByteArray): Int {
}
class IosDriverBackend(private val udid: String) : DriverBackend {
private val driver: maestro.drivers.IOSDriver
private lateinit var driver: maestro.drivers.IOSDriver
private val reconnectLock = java.util.concurrent.locks.ReentrantLock()
init {
val httpClient = xcuitest.api.OkHttpClientInstance.get()
@@ -509,50 +510,90 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
val device = ios.LocalIOSDevice(udid, xcTestDevice, simctlDevice, maestro.utils.NoopInsights)
driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics)
driver.open()
warmup()
}
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {
private fun warmup() {
var warmupErr: Exception? = null
repeat(3) { attempt ->
try {
driver.contentDescriptor(false)
warmupErr = null
return@repeat
} catch (e: Exception) {
warmupErr = e
if (attempt < 2) Thread.sleep(500)
}
}
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()
}
}
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) = withReconnect {
runCatching { driver.stopApp(bundleId) }
if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, env, java.util.UUID.randomUUID())
}
override fun terminate(bundleId: String) = driver.stopApp(bundleId)
override fun terminate(bundleId: String) = withReconnect { driver.stopApp(bundleId) }
override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y))
override fun tap(x: Int, y: Int) = withReconnect { driver.tap(maestro.Point(x, y)) }
override fun tapSelector(selector: String) {
override fun tapSelector(selector: String) = withReconnect {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return
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) = driver.inputText(text)
override fun inputText(text: String) = withReconnect { driver.inputText(text) }
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) = withReconnect {
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L))
}
override fun pressKey(key: String) {
override fun pressKey(key: String) = withReconnect {
StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
}
Unit
}
override fun screenshot(): Triple<ByteArray, Int, Int> {
override fun screenshot(): Triple<ByteArray, Int, Int> = withReconnect {
val buf = okio.Buffer()
driver.takeScreenshot(buf, false)
val bytes = buf.readByteArray()
return Triple(bytes, pngWidth(bytes), pngHeight(bytes))
Triple(bytes, pngWidth(bytes), pngHeight(bytes))
}
override fun hierarchy(): String =
override fun hierarchy(): String = withReconnect {
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false))
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) {
override fun waitForIdle(durationMillis: Long) = withReconnect {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
Unit
}
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }