feat(sidecar): structural-hash settle poll

Add pollUntilStable and structuralHash helpers; wire them into the
Stub, Maestro, and iOS backends' waitForIdle. The structural hash
ignores bounds-only flicker (measure passes) but trips on any change
in resource-id/class/content-desc/text, so a Compose cross-fade where
both source and destination composables are momentarily alive no
longer slips through Maestro's waitForAppToSettle and contaminates
the next hierarchy fetch.
This commit is contained in:
pj committed 2026-05-30 21:50:46 +05:30
1 parent 50e03244d6
commit cd4bd5a194
1 file changed
+100 -5
@@ -16,6 +16,75 @@ interface DriverBackend {
fun metrics(bundleId: String): MetricsSample
}
internal const val STABILITY_POLL_INTERVAL_MILLIS = 80L
// pollUntilStable returns as soon as two consecutive snapshot() results are
// structurally identical, capped at timeoutMillis. snapshot must omit
// transient attributes (e.g. measure-pass bounds) so layout-only flicker
// during a cross-fade doesn't extend the wait.
internal fun pollUntilStable(timeoutMillis: Long, snapshot: () -> String?) {
if (timeoutMillis <= 0) return
val deadline = System.currentTimeMillis() + timeoutMillis
var prior = try {
snapshot()
} catch (_: Exception) {
null
}
while (System.currentTimeMillis() < deadline) {
Thread.sleep(STABILITY_POLL_INTERVAL_MILLIS)
val current = try {
snapshot()
} catch (_: Exception) {
null
}
if (prior != null && current != null && prior == current) return
prior = current
}
}
// structuralHash hashes a Maestro TreeNode-shaped JSON string by walking it
// and concatenating only the stable identity attributes (resource-id, class,
// content-desc, text), in tree order. The transient `bounds` and per-frame
// layout coordinates are excluded so a measure-pass that shifts pixels
// without changing what's on screen does not extend the wait.
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 builder = StringBuilder()
walkForStructuralHash(root, builder)
builder.toString()
} catch (_: Exception) {
treeJson
}
}
private val STABLE_ATTRIBUTE_KEYS = listOf(
"resource-id", "resourceId",
"class", "className",
"content-desc", "contentDescription", "accessibilityText",
"text",
"testTag", "identifier", "accessibilityIdentifier",
)
private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode, out: StringBuilder) {
out.append('(')
val attributes = node.get("attributes")
if (attributes != null && attributes.isObject) {
for (key in STABLE_ATTRIBUTE_KEYS) {
val value = attributes.get(key) ?: continue
if (value.isNull) continue
out.append(key).append(':').append(value.asText()).append('|')
}
}
val children = node.get("children")
if (children != null && children.isArray) {
for (child in children) walkForStructuralHash(child, out)
}
out.append(')')
}
data class MetricsSample(
val cpuPercent: Double,
val heapBytes: Long,
@@ -354,12 +423,18 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
}
override fun waitForIdle(durationMillis: Long) {
// Two-stage settle: an mAnimating poll catches View-system animations,
// then a structural-hash poll catches Compose cross-fades where two
// composables are simultaneously alive but mAnimating is already false.
if (durationMillis <= 0) return
val deadline = System.currentTimeMillis() + durationMillis
while (System.currentTimeMillis() < deadline) {
if (isDeviceIdle()) return
val animationBudget = durationMillis / 2
val animationDeadline = System.currentTimeMillis() + animationBudget
while (System.currentTimeMillis() < animationDeadline) {
if (isDeviceIdle()) break
Thread.sleep(IDLE_POLL_INTERVAL_MILLIS)
}
val structuralBudget = durationMillis - animationBudget
pollUntilStable(structuralBudget) { structuralHash(hierarchy()) }
}
private fun isDeviceIdle(): Boolean {
@@ -427,7 +502,19 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
readLogcat(serial, sinceUnixMillis, minLevel)
override fun waitForIdle(durationMillis: Long) {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
// waitForAppToSettle returns early on Compose cross-fade transitions
// where both source and destination composables are semantically
// alive; a follow-up structural-hash poll lands on a single stable
// frame before the runner reads hierarchy + screenshot concurrently.
val maestroBudget = durationMillis / 2
val stabilityBudget = durationMillis - maestroBudget
driver.waitForAppToSettle(null, null, maestroBudget.toInt())
pollUntilStable(stabilityBudget) {
structuralHash(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
}
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
@@ -614,7 +701,15 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) = withReconnect {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
val maestroBudget = durationMillis / 2
val stabilityBudget = durationMillis - maestroBudget
driver.waitForAppToSettle(null, null, maestroBudget.toInt())
pollUntilStable(stabilityBudget) {
structuralHash(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
Unit
}