refactoring default action layer (#51)

* feat(hierarchy): add editable signal with native derivation

* feat(chrome): emit editable flag in hierarchy dump

* feat(verifier): expose editable on ax element objects

* feat(spec): add editable to selector and element types

* feat(verifier): register typing builtin generator

* feat(verifier): typing builtin types edge-case corpus into editable fields

* feat(spec): export typing builtin generator

* feat(spec): add defaultActions bundle

* feat(spec): export @sanderling/spec/defaults subpath

* feat(folio): layer defaultActions breadth over targeted flows

* test(verifier): typing builtin targets editable fields, declines otherwise

* test(hierarchy): editable derivation and selector matching

* test(spec): defaultActions, typing, and defaults barrel resolve

* fix(testrun): alias @sanderling/spec/defaults for the bundler

* test(chrome): editable flag for inputs, textarea, contenteditable

* feat(spec): typing builtin for the web (V8) action path

* chore(folio): auto-boot a bootable AVD in just test/install when none connected

* feat(driver): add ForegroundChecker optional capability

* feat(android): detect foreground package via adb dumpsys

* feat(sidecar): implement ForegroundApp via adb for android

* feat(runner): relaunch app when foreground escapes during exploration

* fix(spec): drop hardware back from defaultActions to stay in-app

* feat(spec): add DoubleTap action type and constructor

* feat(spec): wire DoubleTap through web-runtime serializer

* feat(verifier): bind doubleTap and decode DoubleTap actions

* feat(runner): dispatch DoubleTap as two taps inside one step

* test(doubleTap): cover constructor, verifier round-trip, and runner dispatch

* feat(folio): add noDuplicateTxnPerStep invariant and doubleSubmitTxn action

* fix(folio): track ledger row count across non-ledger steps; pin reproducer seed

* feat(spec): add doubleTaps random-target builtin to defaultActions

* feat(verifier): add doubleTaps random-target generator

* refactor(folio): drop doubleSubmitTxn; fuzzer surfaces double-submit via defaultActions

* fix(folio): make ledgerRowsSeen monotonic to suppress transient-render false positives

* feat(verifier): track newly-violated property set per step

Sticky `always(P)` violations re-surfaced on every step after onset,
flooding traces and summaries with duplicate records. EvaluateProperties
now diffs against the prior verdict map and records the onset set; a new
NewlyViolatedProperties accessor exposes it so callers can emit each
violation exactly once at its onset step. The verdict-map return is
preserved for residual / current-verdict consumers.

* refactor(runner): emit onset-only violations to trace and summary

Switch the per-step violation list from the sticky verdict map to the
verifier's onset set. Each property now appears exactly once across a
run: at the step it first violates, not on every subsequent step where
the residual stays false. Removes the dead violationNames helper.

* style(verifier): use maps.Copy for verdict snapshot

* fix(folio): make login spec content-driven (idempotent across re-entries)

* fix(verifier): canonicalize selector strings

Object/chain JS selectors used to fall through to goja's default
stringification, producing "[object Object]" tags that surfaced as
garbage in trace.action.selector. Emit canonical "k:v" / " > "-joined
strings instead so the tag round-trips back through the hierarchy
selector grammar.

* refactor(folio): replace txn invariants with balanceMatchesAddedTxn

Collapse noDuplicateTxnPerStep and newTxnChangesBalance into a single
per-row property: every newly-appearing ledger row's signed amount must
match the ledger balance delta. A double-submit lands two rows whose
individual amounts cannot both equal the aggregate delta, so each row
fires the property, catching both the row-count and balance-math
classes of bug under one semantic invariant.

* refactor(trace): drop WriteScreenshotAfter

Only one screenshot per step is captured now (concurrently with
hierarchy after settle), so the -after.png variant is unused.

* refactor(runner): one concurrent screenshot per step

Move screenshot capture into the post-action errgroup so it observes
the same UI moment as the hierarchy fetch. Drop the pre-action and
deferred -after captures. Skip WaitForIdle when the action is Wait
since the wait itself provides settling time.

* refactor(inspect-ui): use next step's screenshot for state after

Each step now has one screenshot (the moment of observation). The
"state after" view of step N is the same moment as step (N+1)'s
observation, so reuse that file rather than expecting a separate
-after.png.

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

* test(sidecar): cover pollUntilStable and structuralHash

Verify the poll returns on two equal snapshots, after transient
churn, and at the cap when never stable; assert the hash ignores
bounds-only flicker and detects content changes.

* feat(spec): accept optional name on extract()

Add an (name, getter) overload so each extractor handle carries a
debuggable label that future trace fields (per-step diffs) can key
off. The web-runtime falls back to extractor_\${index} when none is
supplied so existing call sites keep working unchanged.

* test(spec): cover extract name overload

Verify the runtime receives an undefined name in the legacy shape,
the supplied name in the (name, getter) shape, and that
extract("name") with no getter throws.

* feat(verifier): name extractors for diff surfacing

bindExtract accepts an optional name argument; falls back to
extractor_N when omitted. The name is stored on extractorState
alongside prev/curr value caches that the next change will use to
emit per-step diffs.

* chore(folio): name every extract() call

Give each extractor in the Folio spec a debuggable label so the
inspect UI can render extractor-value diffs at violation steps
keyed by intent (ledgerRows, route, ledgerBalance, ...) rather
than by registration index.

* feat(verifier): track extractor value transitions

Cache each extractor's prior and current JSON-encoded value during
PushSnapshot; expose ChangedExtractors to surface per-step diffs the
runner can emit into the trace. The first observation flushes every
non-null extractor as a change so the inspect UI shows initial state
breadcrumbs alongside later transitions.

* test(verifier): cover ChangedExtractors diffs

Verify initial snapshot reports both named and fallback-named
extractors, a subsequent change surfaces prev/curr, and a no-op
snapshot leaves the diff empty.

* feat(trace): emit extractor_changes per step

Add ExtractorChanges to trace.Step and a runner helper that converts
the verifier's diff map into the trace shape. The inspect UI keys
its violation breadcrumbs off this field.

* feat(inspect-ui): render extractor-change breadcrumbs at violations

Show prev -> curr for each extractor whose value changed on the
selected step, anchored under the violation row in ActionList.
Long values collapse into <details> so the inline diff stays
readable while the full payload is one click away.

* fix(sidecar): cap stability poll independently of settle budget

The previous shape halved durationMillis between waitForAppToSettle
and the structural poll, then hammered hierarchy() at 80ms intervals
- on Maestro this stacked enough RPCs that hierarchy fetches began
timing out under load and the run stalled. Pass the full budget to
waitForAppToSettle and cap the follow-up structural poll at 600ms
with a 120ms interval, so the device sees at most a handful of
extra hierarchy reads per step.

* feat(cli): default --clear-data on so runs start fresh

* feat(sidecar): streak-based settle with route-transition detection

Two changes layered into the stability poll:

1. stabilitySnapshot returns null while the tree carries more than one
   route-level Screen tag (resource-id / testTag / identifier ending
   in "Screen"), so the poll cannot declare a NavHost cross-fade
   stable. Apps following the Compose route convention get this
   detection for free; apps that don't fall through to the generic
   signal below.

2. pollUntilStable now requires an uninterrupted stable streak of at
   least MIN_STABLE_STREAK_MILLIS rather than just N consecutive
   matches. A late transition that fires after a brief calm window
   breaks the streak instead of slipping past. Interval widened to
   250ms so UiAutomation isn't hammered under fuzz load.

* test(sidecar): cover streak reset and route-transition rejection

Verify the poll honors MIN_STABLE_STREAK_MILLIS, that a transient
mid-stream change resets the streak, that null returns block streak
progress through a NavHost cross-fade, and that stabilitySnapshot
counts only route-level attribute keys when summing Screen tags.

* feat(runner): re-fetch on transitional hierarchy capture

Some actions trigger async work (DB write, ViewModel coroutine) whose
navigation transition begins after the sidecar settle poll has already
exited. Without intervention, the next iteration's hierarchy fetch
lands mid cross-fade and the verifier observes a partial extractor
state which then surfaces as a false-positive violation at the step
where the transition completes.

fetchSyncedState pairs hierarchy + screenshot in one goroutine and
retries the pair (up to 4 times, 200ms apart) while the captured tree
contains more than one route-level *Screen tag. Steps that observe
no transition get no added cost; steps that catch a transition pay
up to ~600ms extra wall time but record a tree that matches the
post-transition state the property language expects to compare.

* feat(runner): gate first action on app reaching foreground

* test(runner): cover startup foreground gate and back-press

* feat(verifier): scope random-action targets to app package

Random tap/doubleTap/type/swipe candidates now exclude nodes whose package differs from the app under test, so exploration never fuzzes the soft keyboard, system UI, or permission dialogs. An unset app package or an element with no package stays in scope, preserving behavior on iOS.

* feat(testrun): pass app package into verifier scope filter

* test(verifier): cover package-scoped target selection

* feat(hierarchy): derive package from resource-id prefix

The Android sidecar omits an explicit package attribute, so the verifier's package scope filter was a no-op and the keyboard still leaked into targets. Native nodes carry their package as the resource-id prefix; derive it there when the attribute is absent. Compose testTags are colon-less and stay empty, keeping them in scope.

* test(hierarchy): cover package derivation from resource-id

* chore: stop tracking inspect-ui/dist build artifacts

* feat(android): detect focused-window package via dumpsys window

* feat(driver): add FocusedWindowChecker capability

* fix(runner): gate first observe on the app window being drawn, not just resumed

* test(mock): add FocusedWindowApp with foreground mirroring

* test(runner): cover startup gate waiting for app window to draw

* feat(proto): add Snapshot RPC for atomic hierarchy+screenshot

Pairs hierarchy and screenshot in a single response so the runner can
capture both under a backend mutex, avoiding the cross-fade race where
the two reads describe different frames.

* feat(sidecar): add snapshot default on DriverBackend

Default impl calls hierarchy() then screenshot(). The service layer wraps
the call in a mutex so concurrent runners observe a serialized pair.

* feat(sidecar): wire Snapshot handler with serialization lock

Synchronizes backend.snapshot() so concurrent runners observe a
serialized hierarchy+screenshot pair, eliminating the cross-fade race
where two parallel reads describe different frames.

* test(sidecar): cover Snapshot wire path and serialization lock

SnapshotHandlerTest asserts both fields are populated, concurrent calls
are serialized, and the default impl runs hierarchy then screenshot.

* feat(driver): expose Snapshot on DeviceDriver and sidecar client

Snapshot wraps the new atomic-snapshot gRPC: the runner gets hierarchy
and screenshot from one round-trip whose two reads are serialized on
the sidecar side.

* feat(driver): add Snapshot to chrome and mock drivers

The chrome tab is single-threaded so its Snapshot pairs the two reads
without extra locking. The mock records ActionSnapshot so tests can
assert the runner reaches for the paired RPC.

* refactor(runner): observe each step via the atomic Snapshot RPC

fetchSyncedState now issues one Snapshot per attempt so hierarchy and
screenshot describe the same on-device frame. The transitional retry
stays: that case handles a fully-captured but mid cross-fade frame,
which atomic capture cannot fix.

* test(runner): assert step uses Snapshot, not raw hierarchy/screenshot

TestRunner_UsesAtomicSnapshot catches regressions to the two-goroutine
race, and the existing parallel-fetch test now keys off ActionSnapshot.

* test(driver): cover Snapshot in proto descriptor and sidecar client

Adds Snapshot to the descriptor allowlist and a sidecar-client test that
asserts both fields come back over the wire.

* feat(trace): add Transitional flag to Step

* fix(runner): skip verifier for transitional trees after retry budget

When fetchSyncedState exits its retry loop with a tree that still shows a NavHost cross-fade, the runner now marks the step transitional, writes the step + screenshot to the trace, and skips Verifier.PushSnapshot / EvaluateProperties / ChangedExtractors so the previous-to-current extractor advance is not poisoned by transient state. The next clean step's previous still references the prior clean state. NextAction continues to run so the loop never deadlocks on a never-stabilizing screen.

* test(runner): cover transitional step skips verifier and clean control

* refactor(trace): rename Step.Action to Step.NextAction

The trace step's action field is the action chosen FOR THE NEXT iteration
based on observing this step's hierarchy, not the action that produced
this step. Rename Step.Action to Step.NextAction and the JSON tag to
next_action to make causality explicit at the data level.

* refactor(runner): assign trace action to Step.NextAction field

Follows the rename of trace.Step.Action to Step.NextAction. The runner
already computed the next iteration's action here; only the field name
changes.

* refactor(inspect): decode trace step's next_action JSON field

Mirrors the trace schema rename of action to next_action. The summary
shape exposed to the SPA (action_kind/action_label) keeps its current
JSON tags since these are derived labels, not the raw next-action.

* test(inspect): update fixtures to use next_action trace field

Aligns inspect tests with the trace schema rename. Step constructors
now set NextAction and the JSONL fixtures use the next_action tag.

* refactor(inspect-ui): rename Step.action to Step.next_action

Aligns the SPA type and consumers with the trace schema rename. The
StepSummary.action_kind/action_label labels stay unchanged since they
are derived labels, not the raw next-action.

* fix(folio): extract balanceMatchesAddedSum predicate as testable helper

Move the ledger-balance-vs-added-rows predicate into a pure helper module
so the property's logic is unit-testable in isolation. Marks the sanderling
example as an ES module so cross-package ESM imports resolve under node.

* fix(folio): use sum-of-added-rows in balanceMatchesAddedTxn

The old predicate (every row's signed amount equals delta) silently passed
the double-submit bug because two same-amount rows each match the delta in
isolation. Switching to the sum check (addedSum === delta) catches both the
double-submit case and any future multi-row append whose total drifts from
the balance change.

* test(spec): cover balanceMatchesAddedSum single, sum-match, over, under cases

Pins the sum-based predicate: a single new row matching delta and two new
rows summing to delta both hold; two-row over-sum (double-submit) and
under-sum cases both violate.

* fix(build): rebuild sidecar JAR when Kotlin sources change

Without source-file deps on $(SIDECAR_JAR), make never re-ran shadowJar
after a Kotlin edit, so a stale embedded JAR shipped on every install
and the new sidecar code was silently absent at runtime.

* fix(chrome): launch with no-sandbox so headless Chrome starts in CI

* fix(sidecar): type text at cursor instead of clearing the field

InputText now appends at the focus caret, matching the native driver
and the standard mobile-input contract, instead of deleting existing
content first. Adds an injectable command runner so the behavior is
testable without a device.

* test(sidecar): assert InputText types at cursor without clearing

Captures the adb command stream and verifies a single input-text call
with no preceding delete keyevents, plus the adb escaping cases.

* feat(proto): add LongPress RPC

* chore(proto): regenerate Go stubs for LongPress

* feat(driver): add LongPress to DeviceDriver interface

* feat(sidecar): add LongPress client method

* feat(mock): record LongPress action

* feat(chrome): implement LongPress as press-and-hold

* feat(sidecar): implement longPress across backends

* feat(sidecar): dispatch LongPress RPC to backend

* test(sidecar): cover LongPress dispatch

* test(sidecar): implement longPress in snapshot test backend

* feat(verifier): add LongPress and Scroll action kinds

* feat(folio-spec): predicate that gates balance check on TxnSubmit tap

Replaces the row-sum predicate (which always held by construction since
balance is derived from rows in Folio) with one that compares the typed
amount to the actual balance delta after a tap on TxnSubmit. Catches the
planted double-submit bug.

* feat(folio-spec): wire submitMovesBalanceByTypedAmount property

Adds lastAction and totalBalance extractors and uses them in the new
property. Drops ledgerRows/ledgerBalance extractors since nothing else
referenced them.

* feat(verifier): wire longPresses and scrolls generators

* test(verifier): cover longPresses and scrolls generators

* test(folio-spec): unit tests for submitChangesBalanceByTypedAmount

Covers single vs double submit, the DoubleTap variant, vacuous cases
(null action, wrong kind, wrong target, zero typed), and selector-as-
object coercion.

* feat(spec): add LongPress and Scroll authoring surface

* feat(spec): no-op LongPress and Scroll in web runtime

* feat(spec): re-export longPresses and scrolls as opt-in generators

* test(spec): cover LongPress and Scroll runtime members

* test(proto): expect LongPress in service descriptor

* feat(runner): dispatch LongPress and Scroll actions

* test(runner): cover LongPress and Scroll dispatch

* docs(action-space): move LongPress, Scroll, DoubleTap to current actions

* fix(runner): mark nil/empty hierarchy as transitional

A failed or empty sidecar hierarchy fetch was pushed straight to the
verifier, letting spec extractors crash with "Cannot read property 'map'
of undefined" when findAll returned null. Treat that case like a
transitional capture: skip the verifier push, still record the step, and
keep the loop progressing.

* fix(verifier): populate Action.On when tap chooser picks an element

Coordinate-targeted Taps/DoubleTaps left On empty, so action-gated
properties reading lastAction.on couldn't tell which target was hit and
were vacuously skipped. Resolve the picked element to a stable
key:value selector (resource-id, testTag, text, desc) and validate it
resolves back to the same element so we don't accidentally redirect the
tap to a sibling that shares the identifier.

* fix(folio): add parseTypedAmount helper matching app's parseCents

Raw user input like "50" must become 5000 cents, not 50. The existing
parseDollarCents helper strips non-digits and so reads "50" as 50 cents,
which is correct for formatted balance text but off by 100x for raw
input from the amount field.

* fix(folio): parse raw amount input as cents in submit predicate

txnAmountField holds raw user keystrokes, not formatted balance text.
Route it through parseTypedAmount so "50" reads as $50, matching how
the app commits the transaction.

* fix(folio): carry forward total balance across off-screen transitions

AddTransactionScreen shows neither AccountCard nor LedgerBalance, so the
extractor used to report 0 at the step before submit. That made every
non-zero current balance look like the full delta and tripped the typed
amount property on every honest submit. Remember the last-seen sum and
return it whenever the current snapshot has no balance signal.

* test(folio): cover submit predicate with raw typed-amount inputs

Pipes realistic raw keystrokes through parseTypedAmount + the predicate
so single submits clear and double submits fire as expected.

* feat(folio): add computeHomeTotalBalance helper

Pure helper that tracks Home multi-account total only and carries the last
Home sum across off-Home steps. Ledger's single-account balance is excluded
because mixing it would corrupt cross-screen scale comparisons.

* fix(folio): totalBalance carrier tracks only Home, not Ledger

Home cardSum is a multi-account total; Ledger's LedgerBalance is a single
account on a different scale. Blending them in the carrier produced bogus
cross-screen deltas (prev from Ledger, curr from Home), triggering false
positives in submitMovesBalanceByTypedAmount. Restrict the carrier to
Home AccountCard totals via the computeHomeTotalBalance helper.

* test(spec): cover computeHomeTotalBalance carrier behaviour

Tests Home sums, carrier passthrough on off-Home steps, the Ledger
scale-mismatch case, and a Home > off-Home > Home sequence.

* feat(runner): treat transient apply errors as transitional steps

Sidecar input RPCs occasionally hang with DEADLINE_EXCEEDED or
UNAVAILABLE on long fuzzing runs. The per-step loop previously
propagated any applyAction error and killed the run after a single
flake. Detect transient gRPC failures via status.FromError, mark the
step transitional, skip the post-action idle poll, and continue to the
next step. Fatal errors (outer ctx cancellation, non-transient codes,
verifier crashes) still propagate.

* test(runner): cover transient apply error resilience

TestRunner_TransientApplyErrorMarksTransitional drives the runner
through a wrapper that fails the first TapSelector with a gRPC
DeadlineExceeded then succeeds. Asserts the run does not exit, the
failed step is marked transitional with no violations, and the next
step runs cleanly. TestIsTransientApplyError_Classification covers the
helper's matching rules directly so future code changes don't quietly
drop a transient case.

* fix(folio): gate submit-balance property on Home route landing

totalBalance is only freshly computed when AccountCards are visible on
Home; off-Home landings return the carrier and would false-fire the
property, latching always(next(F)) to false and masking the real
double-submit bug. Skip vacuously when route is not "home".

* test(spec): cover route gate in submit-balance predicate

Adds route arg to existing cases (all use "home") and adds five new
cases: ledger landing with stale carrier, add-transaction with
double-insert delta, null route, plus home-landing positive and
double-insert negative cases anchoring the gate's allow path.
This commit is contained in:
pj authored and GitHub committed 2026-06-01 12:48:51 +05:30
1 parent f572c8ba66
commit 88db9653e5
65 files changed
+4748 -596

No files matched your search

@@ -8,12 +8,165 @@ interface DriverBackend {
fun inputText(text: String)
fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long)
fun pressKey(key: String)
fun longPress(x: Int, y: Int)
fun screenshot(): Triple<ByteArray, Int, Int>
fun hierarchy(): String
fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine>
fun waitForIdle(durationMillis: Long)
fun healthy(): Boolean
fun metrics(bundleId: String): MetricsSample
// snapshot captures hierarchy then screenshot back-to-back. The service
// layer holds a mutex around the call so concurrent callers observe a
// 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())
}
data class SnapshotSample(
val hierarchyJson: String,
val screenshot: Triple<ByteArray, Int, Int>,
)
// STABILITY_POLL_INTERVAL_MILLIS is set wide enough that UiAutomation /
// Maestro's contentDescriptor doesn't get hammered: tighter intervals were
// observed to back the sidecar gRPC stream up under fuzz load to the point
// of triggering 120s inputText deadlines.
internal const val STABILITY_POLL_INTERVAL_MILLIS = 250L
internal const val STABILITY_POLL_CAP_MILLIS = 2000L
// MIN_STABLE_STREAK_MILLIS is the duration the tree must stay structurally
// identical (and non-transitional) before the poll declares settle. Actions
// whose effect is async (e.g. tap-submit fires a DB write that later calls
// popBackStack) leave the UI momentarily stable before the navigation
// transition fires; requiring an uninterrupted streak of this length means
// any churn that starts during the window resets the clock instead of being
// missed.
internal const val MIN_STABLE_STREAK_MILLIS = 750L
// pollUntilStable returns when the snapshot has been non-null and equal to
// itself for an uninterrupted stretch of at least MIN_STABLE_STREAK_MILLIS,
// capped at timeoutMillis. snapshot must omit transient attributes (e.g.
// measure-pass bounds) so layout-only flicker doesn't extend the wait, and
// must return null when the snapshot looks transitional (e.g. mid NavHost
// cross-fade) so the streak resets and the loop keeps polling instead of
// declaring a partial state stable.
internal fun pollUntilStable(timeoutMillis: Long, snapshot: () -> String?) {
if (timeoutMillis <= 0) return
val deadline = System.currentTimeMillis() + timeoutMillis
var prior = try {
snapshot()
} catch (_: Exception) {
null
}
var streakStart = 0L
while (System.currentTimeMillis() < deadline) {
Thread.sleep(STABILITY_POLL_INTERVAL_MILLIS)
val current = try {
snapshot()
} catch (_: Exception) {
null
}
val now = System.currentTimeMillis()
if (prior != null && current != null && prior == current) {
if (streakStart == 0L) streakStart = now
if (now - streakStart >= MIN_STABLE_STREAK_MILLIS) return
} else {
streakStart = 0L
}
prior = current
}
}
// stabilitySnapshot probes the current hierarchy for both structural shape
// and route-transition state. Returns null while more than one route-level
// destination tag (resource-id/testTag/identifier ending in "Screen") is
// present, because NavHost keeps both source and destination composables
// alive during a cross-fade and a snapshot taken in that window is
// unreliable (lazy lists in the incoming screen mount over multiple frames).
internal fun stabilitySnapshot(treeJson: String): String? {
if (treeJson.isBlank()) return ""
if (countRouteScreens(treeJson) > 1) return null
return structuralHash(treeJson)
}
private val ROUTE_TAG_KEYS = setOf(
"resource-id", "resourceId", "testTag",
"identifier", "accessibilityIdentifier",
)
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)
countRouteScreens(root)
} catch (_: Exception) {
0
}
}
private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): Int {
var count = 0
val attributes = node.get("attributes")
if (attributes != null && attributes.isObject) {
for (key in ROUTE_TAG_KEYS) {
val value = attributes.get(key) ?: continue
if (value.isNull) continue
if (value.asText().endsWith("Screen")) {
count++
break
}
}
}
val children = node.get("children")
if (children != null && children.isArray) {
for (child in children) count += countRouteScreens(child)
}
return count
}
// 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(
@@ -119,7 +272,20 @@ private fun parseKb(line: String): Long? {
return parts[1].toLongOrNull()
}
class StubDriverBackend(private val platform: String) : DriverBackend {
private fun execAdb(arguments: List<String>) {
try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
command.inputStream.bufferedReader().readText()
command.waitFor()
} catch (cause: Exception) {
println("adb ${arguments.joinToString(" ")} failed: $cause")
}
}
class StubDriverBackend(
private val platform: String,
private val commandRunner: (List<String>) -> Unit = ::execAdb,
) : DriverBackend {
@Volatile var launchCount: Int = 0
private set
@Volatile var lastBundleId: String? = null
@@ -157,16 +323,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return null
}
internal const val MAX_CLEAR_DELETES: Int = 1024
internal fun buildClearKeyevents(textLength: Int): List<String> {
if (textLength <= 0) return emptyList()
val deletes = minOf(textLength, MAX_CLEAR_DELETES)
val args = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END")
repeat(deletes) { args.add("KEYCODE_DEL") }
return args
}
internal fun escapeForAdbInputText(text: String): String {
val sb = StringBuilder(text.length)
for (ch in text) {
@@ -180,24 +336,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return sb.toString()
}
private val FOCUSED_NODE = Regex(
"<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>",
)
private val TEXT_ATTRIBUTE = Regex("\\btext=\"([^\"]*)\"")
internal fun parseFocusedText(xml: String): String? {
val node = FOCUSED_NODE.find(xml) ?: return null
val match = TEXT_ATTRIBUTE.find(node.groupValues[1]) ?: return ""
return decodeXmlAttribute(match.groupValues[1])
}
private fun decodeXmlAttribute(value: String): String = value
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
internal val KEY_MAP: Map<String, String> = mapOf(
"back" to "KEYCODE_BACK",
"home" to "KEYCODE_HOME",
@@ -261,31 +399,15 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
override fun inputText(text: String) {
lastInputText = text
clearFocusedField()
runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text)))
}
private fun clearFocusedField() {
val current = focusedFieldText() ?: return
if (current.isEmpty()) return
runAdb(buildClearKeyevents(current.length))
}
private fun focusedFieldText(): String? {
val xml = try {
hierarchy()
} catch (cause: Exception) {
println("inputText: hierarchy dump failed: $cause")
return null
}
if (xml.isBlank() || xml == "<hierarchy/>") return null
return parseFocusedText(xml)
}
@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) {
lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis)
@@ -307,20 +429,17 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
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"))
}
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)
private fun runAdb(arguments: List<String>) {
try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
command.inputStream.bufferedReader().readText()
command.waitFor()
} catch (cause: Exception) {
println("adb ${arguments.joinToString(" ")} failed: $cause")
}
}
private fun runAdb(arguments: List<String>) = commandRunner(arguments)
override fun screenshot(): Triple<ByteArray, Int, Int> {
return try {
@@ -354,12 +473,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 short structural-hash poll catches Compose cross-fades where
// two composables are simultaneously alive but mAnimating is already
// false. The structural poll is hard-capped so we don't hammer the
// device with repeat hierarchy fetches when the UI never stabilizes.
if (durationMillis <= 0) return
val deadline = System.currentTimeMillis() + durationMillis
while (System.currentTimeMillis() < deadline) {
if (isDeviceIdle()) return
val animationDeadline = System.currentTimeMillis() + durationMillis
while (System.currentTimeMillis() < animationDeadline) {
if (isDeviceIdle()) break
Thread.sleep(IDLE_POLL_INTERVAL_MILLIS)
}
pollUntilStable(STABILITY_POLL_CAP_MILLIS) { stabilitySnapshot(hierarchy()) }
}
private fun isDeviceIdle(): Boolean {
@@ -396,6 +521,8 @@ 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 tapSelector(selector: String) {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return
@@ -427,7 +554,20 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
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.
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 }
@@ -580,6 +720,8 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
override fun tap(x: Int, y: Int) = withReconnect { driver.tap(maestro.Point(x, y)) }
override fun longPress(x: Int, y: Int) = withReconnect { driver.longPress(maestro.Point(x, y)) }
override fun tapSelector(selector: String) = withReconnect {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return@withReconnect
@@ -615,6 +757,12 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
override fun waitForIdle(durationMillis: Long) = withReconnect {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
Unit
}
@@ -16,6 +16,7 @@ import dev.sanderling.driver.v1.Point
import dev.sanderling.driver.v1.PressKeyRequest
import dev.sanderling.driver.v1.RecentLogsRequest
import dev.sanderling.driver.v1.Selector
import dev.sanderling.driver.v1.SnapshotResponse
import dev.sanderling.driver.v1.SwipeRequest
import dev.sanderling.driver.v1.Text
import io.grpc.stub.StreamObserver
@@ -27,6 +28,7 @@ class DriverService(
) : DriverGrpc.DriverImplBase() {
private val launchedBundleId = AtomicReference<String?>(null)
private val snapshotLock = Any()
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
@@ -51,6 +53,13 @@ class DriverService(
}
}
override fun longPress(request: Point, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.longPress(request.x, request.y)
Empty.getDefaultInstance()
}
}
override fun tapSelector(request: Selector, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.tapSelector(request.value)
@@ -116,6 +125,23 @@ class DriverService(
}
}
override fun snapshot(request: Empty, responseObserver: StreamObserver<SnapshotResponse>) {
runRpc(responseObserver) {
val sample = synchronized(snapshotLock) { backend.snapshot() }
val (png, width, height) = sample.screenshot
SnapshotResponse.newBuilder()
.setHierarchy(HierarchyJSON.newBuilder().setJson(sample.hierarchyJson).build())
.setScreenshot(
Image.newBuilder()
.setPng(ByteString.copyFrom(png))
.setWidth(width)
.setHeight(height)
.build(),
)
.build()
}
}
override fun waitForIdle(request: Duration, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.waitForIdle(request.millis)
@@ -61,6 +61,19 @@ class DriverServiceTest {
assertEquals(100 to 250, backend.lastTap)
}
@Test fun longPressForwardsCoordinates() {
var observed: Pair<Int, Int>? = null
val backend = object : DriverBackend by StubDriverBackend("android") {
override fun longPress(x: Int, y: Int) {
observed = x to y
}
}
val client = newClient(backend)
client.longPress(Point.newBuilder().setX(400).setY(900).build())
assertEquals(400 to 900, observed)
}
@Test fun inputTextForwardsValue() {
val backend = StubDriverBackend("android")
val client = newClient(backend)
@@ -1,90 +0,0 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class InputTextClearTest {
@Test fun buildClearKeyeventsEmptyWhenNothingToDelete() {
assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(0))
assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(-3))
}
@Test fun buildClearKeyeventsPrefixesMoveEndThenOneDelPerChar() {
val args = StubDriverBackend.buildClearKeyevents(3)
assertEquals(listOf("shell", "input", "keyevent", "KEYCODE_MOVE_END",
"KEYCODE_DEL", "KEYCODE_DEL", "KEYCODE_DEL"), args)
}
@Test fun buildClearKeyeventsCapsAtMaxClearDeletes() {
val huge = StubDriverBackend.MAX_CLEAR_DELETES * 10
val args = StubDriverBackend.buildClearKeyevents(huge)
val deletes = args.count { it == "KEYCODE_DEL" }
assertEquals(StubDriverBackend.MAX_CLEAR_DELETES, deletes)
assertEquals("KEYCODE_MOVE_END", args[3])
}
@Test fun escapeForAdbInputTextSubstitutesSpaces() {
assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world"))
}
@Test fun escapeForAdbInputTextEscapesShellMetacharacters() {
val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f")
assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped)
}
@Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() {
assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'"))
assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\""))
assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\"))
}
@Test fun escapeForAdbInputTextLeavesSimpleTextAlone() {
assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34"))
assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee"))
assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5"))
}
@Test fun parsesTextFromFocusedNode() {
val xml = """
<hierarchy>
<node text="ignored" focused="false"/>
<node resource-id="app:id/email" text="old@value" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("old@value", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() {
val xml = """<hierarchy><node focused="true"/></hierarchy>"""
assertEquals("", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsNullWhenNoFocusedNode() {
val xml = """<hierarchy><node text="x" focused="false"/></hierarchy>"""
assertNull(StubDriverBackend.parseFocusedText(xml))
}
@Test fun decodesXmlEntitiesInAttribute() {
val xml = """<hierarchy><node text="a&amp;b&lt;c&quot;d" focused="true"/></hierarchy>"""
assertEquals("a&b<c\"d", StubDriverBackend.parseFocusedText(xml))
}
@Test fun picksFirstFocusedNodeWhenMultiplePresent() {
val xml = """
<hierarchy>
<node text="first" focused="true"/>
<node text="second" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("first", StubDriverBackend.parseFocusedText(xml))
}
}
@@ -0,0 +1,38 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class InputTextTest {
@Test fun inputTextTypesAtCursorWithoutClearing() {
val commands = mutableListOf<List<String>>()
val backend = StubDriverBackend("android") { commands.add(it) }
backend.inputText("Emergency Fund")
assertEquals(listOf(listOf("shell", "input", "text", "Emergency%sFund")), commands)
}
@Test fun escapeForAdbInputTextSubstitutesSpaces() {
assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world"))
}
@Test fun escapeForAdbInputTextEscapesShellMetacharacters() {
val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f")
assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped)
}
@Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() {
assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'"))
assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\""))
assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\"))
}
@Test fun escapeForAdbInputTextLeavesSimpleTextAlone() {
assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34"))
assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee"))
assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5"))
}
}
@@ -0,0 +1,135 @@
package dev.sanderling.sidecar
import dev.sanderling.driver.v1.DriverGrpc
import dev.sanderling.driver.v1.Empty
import io.grpc.ManagedChannel
import io.grpc.inprocess.InProcessChannelBuilder
import io.grpc.inprocess.InProcessServerBuilder
import io.grpc.testing.GrpcCleanupRule
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SnapshotHandlerTest {
@get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule()
private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub {
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(),
)
return DriverGrpc.newBlockingStub(channel)
}
@Test fun snapshotReturnsBothHierarchyAndScreenshot() {
// The default snapshot() impl calls hierarchy() then screenshot() on
// the same backend instance; using interface delegation here would
// forward those calls to the delegate, not these overrides. Override
// snapshot() directly so the test exercises the wire path end-to-end.
val backend = object : DriverBackend by StubDriverBackend("android") {
override fun snapshot(): SnapshotSample =
SnapshotSample("{\"x\":1}", Triple(byteArrayOf(7, 8, 9), 1080, 2340))
}
val client = newClient(backend)
val response = client.snapshot(Empty.getDefaultInstance())
assertEquals("{\"x\":1}", response.hierarchy.json)
assertEquals(1080, response.screenshot.width)
assertEquals(2340, response.screenshot.height)
assertEquals(3, response.screenshot.png.size())
}
@Test fun defaultSnapshotPairsHierarchyAndScreenshotSequentially() {
// Covers the DriverBackend default impl: hierarchy() runs before
// screenshot() so a transitional re-fetch keeps the screenshot
// aligned with the final hierarchy snapshot the runner accepts.
val callOrder = mutableListOf<String>()
val backend = object : DriverBackend {
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {}
override fun terminate(bundleId: String) {}
override fun tap(x: Int, y: Int) {}
override fun tapSelector(selector: String) {}
override fun inputText(text: String) {}
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {}
override fun pressKey(key: String) {}
override fun longPress(x: Int, y: Int) {}
override fun screenshot(): Triple<ByteArray, Int, Int> {
callOrder.add("screenshot")
return Triple(byteArrayOf(1), 0, 0)
}
override fun hierarchy(): String {
callOrder.add("hierarchy")
return "{}"
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) {}
override fun healthy(): Boolean = true
override fun metrics(bundleId: String): MetricsSample = MetricsSample(0.0, 0L, 0L)
}
backend.snapshot()
assertEquals(listOf("hierarchy", "screenshot"), callOrder)
}
@Test fun concurrentSnapshotsAreSerialized() {
// The recording backend asserts no overlap: a sleep-bearing snapshot()
// makes any race observable as inFlight > 1.
val inFlight = AtomicInteger(0)
val maxObserved = AtomicInteger(0)
val callCount = AtomicInteger(0)
val lock = ReentrantLock()
val recordingBackend = object : DriverBackend by StubDriverBackend("android") {
override fun snapshot(): SnapshotSample {
val now = inFlight.incrementAndGet()
try {
lock.lock()
try {
if (now > maxObserved.get()) maxObserved.set(now)
} finally {
lock.unlock()
}
Thread.sleep(50)
callCount.incrementAndGet()
return SnapshotSample("{}", Triple(ByteArray(0), 0, 0))
} finally {
inFlight.decrementAndGet()
}
}
}
// Use a real (multi-threaded) executor on the server side so the service
// is not artificially serialized by directExecutor.
val serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", backend = recordingBackend)
grpcCleanup.register(
InProcessServerBuilder.forName(serverName).addService(service).build().start(),
)
val channel: ManagedChannel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).build(),
)
val stub = DriverGrpc.newBlockingStub(channel)
val pool = Executors.newFixedThreadPool(4)
try {
val tasks = (1..4).map {
pool.submit { stub.snapshot(Empty.getDefaultInstance()) }
}
for (task in tasks) task.get(5, TimeUnit.SECONDS)
} finally {
pool.shutdownNow()
}
assertEquals(4, callCount.get())
assertTrue(
maxObserved.get() <= 1,
"snapshot calls overlapped (maxObserved=${maxObserved.get()}); the service lock did not serialize them",
)
}
}
@@ -0,0 +1,149 @@
package dev.sanderling.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class StabilityPollTest {
@Test fun returnsAfterUninterruptedStableStreak() {
val start = System.currentTimeMillis()
pollUntilStable(3000L) { "stable" }
val elapsed = System.currentTimeMillis() - start
assertTrue(
elapsed >= MIN_STABLE_STREAK_MILLIS,
"must observe a stable streak of at least ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms",
)
assertTrue(elapsed < 3000L, "should not run to cap when stable, elapsed=${elapsed}ms")
}
@Test fun streakResetsOnAnyChange() {
// A late transition that fires after the prior streak has already
// begun must reset the clock: the post-transition stable window has
// to start over and meet MIN_STABLE_STREAK_MILLIS from scratch.
var calls = 0
val start = System.currentTimeMillis()
// First 4 samples are "calm", then 1 transient change, then "stable"
// forever - the calm prefix is meaningless because of the transition.
pollUntilStable(3000L) {
calls++
when {
calls <= 4 -> "calm"
calls == 5 -> "transient"
else -> "stable"
}
}
val elapsed = System.currentTimeMillis() - start
assertTrue(
elapsed >= MIN_STABLE_STREAK_MILLIS,
"post-transition streak must reach ${MIN_STABLE_STREAK_MILLIS}ms, elapsed=${elapsed}ms",
)
assertTrue(calls >= 10, "expected enough samples to span calm + transient + stable streak, got $calls")
}
@Test fun transitionalNullsForceLoopToKeepWaiting() {
// Simulates a NavHost cross-fade where stabilitySnapshot returns null
// for several samples (multi-screen detected) before the destination
// screen settles. Streak must not start while null returns persist.
var calls = 0
val transitionalCount = 5
pollUntilStable(3000L) {
calls++
if (calls <= transitionalCount) null else "stable"
}
assertTrue(
calls >= transitionalCount + 2,
"must consume the null prefix before starting the streak, calls=$calls",
)
}
@Test fun stopsAtDeadlineWhenNeverStable() {
var calls = 0
val budget = 240L
val start = System.currentTimeMillis()
pollUntilStable(budget) {
calls++
"frame-$calls"
}
val elapsed = System.currentTimeMillis() - start
assertTrue(elapsed in budget..(budget + 200L), "expected to hit cap, elapsed=$elapsed")
}
@Test fun zeroBudgetReturnsImmediately() {
var calls = 0
pollUntilStable(0L) {
calls++
"x"
}
assertEquals(0, calls)
}
@Test fun structuralHashIgnoresBoundsAndIdenticalForSemanticallyEqualTrees() {
val a = """
{"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2340]"},
"children":[
{"attributes":{"resource-id":"LoginEmail","bounds":"[10,10,1070,100]","text":"a@b"},"children":[]}
]}
""".trimIndent()
val b = """
{"attributes":{"resource-id":"LoginScreen","bounds":"[0,0,1080,2342]"},
"children":[
{"attributes":{"resource-id":"LoginEmail","bounds":"[10,11,1070,101]","text":"a@b"},"children":[]}
]}
""".trimIndent()
assertEquals(structuralHash(a), structuralHash(b), "bounds-only flicker must not change hash")
}
@Test fun structuralHashDiffersWhenContentChanges() {
val a = """{"attributes":{"resource-id":"LoginEmail","text":"a@b"},"children":[]}"""
val b = """{"attributes":{"resource-id":"LoginEmail","text":"c@d"},"children":[]}"""
assertTrue(structuralHash(a) != structuralHash(b), "text change must alter hash")
}
@Test fun stabilitySnapshotReturnsNullDuringNavHostCrossFade() {
val midTransition = """
{"attributes":{"resource-id":"root"},
"children":[
{"attributes":{"resource-id":"AddAccountScreen"},"children":[]},
{"attributes":{"resource-id":"HomeScreen"},"children":[
{"attributes":{"resource-id":"AccountCard","text":"a"},"children":[]}
]}
]}
""".trimIndent()
assertEquals(null, stabilitySnapshot(midTransition))
}
@Test fun stabilitySnapshotReturnsHashOnSingleScreen() {
val singleScreen = """
{"attributes":{"resource-id":"HomeScreen"},
"children":[
{"attributes":{"resource-id":"AccountCard","text":"a"},"children":[]}
]}
""".trimIndent()
val hash = stabilitySnapshot(singleScreen)
assertTrue(hash != null && hash.isNotBlank(), "single-screen tree must yield a hash, got $hash")
}
@Test fun stabilitySnapshotIgnoresNonRouteAttributeValues() {
// Random text content ending in "Screen" must not trip the detector;
// only route-level attribute keys (resource-id, testTag, identifier)
// are considered for the route-tag count.
val tree = """
{"attributes":{"resource-id":"HomeScreen"},
"children":[
{"attributes":{"text":"Welcome to MyScreen"},"children":[]}
]}
""".trimIndent()
assertTrue(stabilitySnapshot(tree) != null, "non-route attribute must not be counted as a screen")
}
@Test fun countRouteScreensCountsTestTagAndIdentifier() {
val tree = """
{"attributes":{"resource-id":"root"},
"children":[
{"attributes":{"testTag":"HomeScreen"},"children":[]},
{"attributes":{"accessibilityIdentifier":"LedgerScreen"},"children":[]}
]}
""".trimIndent()
assertEquals(2, countRouteScreens(tree))
}
}