DriverDispatch and the iOS settle gap¶
What landed¶
A new marker interface xyz.block.trailblaze.api.DriverDispatch (in trailblaze-models/commonMain)
documents the cross-driver “dispatch a content-changing action, then wait until the driver’s UI
is settled enough to safely read state” contract. Three drivers now implement it:
AccessibilityDeviceManager(Android) — settle viaUiDevice.waitForIdle(), the platform’s own accessibility-event quiet detector. Pattern was already in place from PR #2843; this PR formalizes it.PlaywrightPageManager(web) — settle via HTTP request drain + navigationloadstate. Method renamed fromwaitForCompletiontodispatchAndAwaitSettlefor cross-driver naming consistency. Pattern landed in PR #2846; this PR formalizes it.ComposeTestTarget(desktop) — settle viawaitForIdle(), Compose’s recomposition queue drain. The three dispatch tools (Click, Type, Scroll) now route throughtarget.dispatchAndAwaitSettle { ... }rather than callingtarget.waitForIdle()inline.
The interface has one method:
suspend fun <R> dispatchAndAwaitSettle(action: suspend () -> R): R
It’s suspend because that’s the union of all drivers’ natural shapes. Playwright’s settle
does real coroutine I/O (request tracking, delay, navigation waits). Android and Compose
implement the same suspend signature but the bodies never actually suspend — they invoke the
action and call their blocking settle primitive (UiDevice.waitForIdle(),
ComposeTestTarget.waitForIdle()) inline. Drivers whose existing call sites are non-coroutine
(Android’s per-gesture fun tap(), fun swipe(), …) keep a small private blocking helper
inside the class (dispatchAndAwaitSettleBlocking) so blocking callers don’t have to enter a
coroutine context. The helper body is identical to the suspend override — tiny duplication
in exchange for keeping the public surface natural at each call site.
What didn’t land, and why¶
iOS — neither path adopts the interface yet.
The Trailblaze iOS surface today goes through two paths:
- iOS Maestro (
HostMaestroTrailblazeAgent) — settle is implicit inside Maestro’sviewHierarchy()quiesce. Trailblaze never sees the signal directly. - iOS Axe (
AxeDeviceManager) — no settle at all. The class has an orphanedwaitForReady(timeoutMs)(300ms sleep) that’s never called post-dispatch.
The cross-driver consistency direction calls for making iOS look like Android and web — but
adopting the interface on the iOS Maestro path using driver.contentDescriptor() as the settle
primitive would deepen the Maestro dependency, the exact direction we just spent PR #2843
moving away from on Android. The Android win was reactive (subscribe to
AccessibilityEvent.TYPE_VIEW_CONTENT_CHANGED / TYPE_WINDOW_STATE_CHANGED, debounce on a
quiet window) rather than the old poll-and-diff of view hierarchies. We want to repeat that
win on iOS rather than wedge Maestro deeper.
The iOS reactive settle observer (follow-on work)¶
iOS has direct equivalents of the Android primitives we used:
UIAccessibility.layoutChangedNotification/screenChangedNotification— system-posted accessibility events fired when the AX tree changes. Direct analog of Android’sTYPE_VIEW_CONTENT_CHANGED/TYPE_WINDOW_STATE_CHANGED. Subscribe once, debounce on a quiet window, push “settled” to the host.CFRunLoopObserveron the main run loop’s idle phase — true reactive idle detection, not a poll-and-diff. Comparable toUiDevice.waitForIdle()but event-driven.- XCUITest’s internal snapshot-ready signal — Apple already tracks this for
XCUIElement.waitForExistence; the underlying implementation is event-driven, not poll based. Not directly exposed but reachable from a custom XCTest runner.
The architectural shape that ports cleanly from Android is: a small observer running inside
the on-device XCTest runner subscribes to (1) + (2), and pushes quiet-window events out over
the host↔device transport. The host’s iOS DriverDispatch implementation calls
bridge.waitForSettled(timeoutMs) after dispatch.
Today there’s no OSS-level on-device iOS runner component we own to extend — the XCUITest bridges that do exist are target-scoped (one per internal app) rather than reusable across arbitrary iOS targets. Standing one up at the OSS layer is the unblocking piece.
Once that observer lands:
- iOS Maestro gets explicit settle that doesn’t go through Maestro — and we can plan the Maestro divorce.
- iOS Axe gets its first real settle (replaces the orphaned 300ms heuristic).
- Both iOS paths can implement
DriverDispatchagainst the same primitive, mirroring the Android shape exactly.
That work is platform-engineering scope (build a runner component, design the wire protocol, land changes on both sides of the device boundary). It’s not a refactor PR; it’s its own piece. This devlog exists to capture the design rationale so when that work starts, the goal is clear: a reactive iOS settle observer, not another Maestro hook.
Pattern: when to add a new driver to DriverDispatch¶
When a new driver lands or an existing one acquires its own (non-Maestro) settle primitive:
- Make the manager implement
DriverDispatchand overridedispatchAndAwaitSettle. The body is whatever runs the action and then waits on the driver’s settle primitive — for drivers whose primitive is blocking, the suspend body just calls it inline (it never actually suspends). - If the driver has existing non-coroutine call sites (per-gesture
funmethods), add a private blocking helper (dispatchAndAwaitSettleBlockingor similar) to avoid pushing those callers into a coroutine context. The helper body mirrors the suspend override. - Route every content-changing method through one of the two so adding a new gesture without the settle wait is impossible by construction.
- Update the
DriverDispatchkdoc to add the manager to the “Current implementers” list. - Update tools that dispatch content changes to call the suspend method rather than inline-settling.
Verifications, reads, and explicit “wait N seconds” tools do NOT go through dispatchAndAwaitSettle.
They use their own poll loops (verify) or call the underlying settle primitive directly (read).