Skip to content

DeviceLab driver: settle via agent, text matching fixes, runFlow timeout#55

Merged
omnarayan merged 18 commits intomainfrom
fix/tap-options
Apr 20, 2026
Merged

DeviceLab driver: settle via agent, text matching fixes, runFlow timeout#55
omnarayan merged 18 commits intomainfrom
fix/tap-options

Conversation

@omnarayan
Copy link
Copy Markdown
Contributor

Summary

Follow-up PR after #53 (now merged). Consolidates 16 commits covering:

DeviceLab agent + driver — settle and text matching

  • UI.waitForSettle RPC (agent + Go): on-device tree-comparison settle, faster and more reliable than hierarchy-fetch fallback.
  • Auto-settle before inputText/eraseText so key events don't fire mid-transition.
  • Clickable-ancestor promotion on .clickable(true) text selectors: walks up from a non-clickable text match (e.g. a "Sign In" TextView child) to the nearest clickable ancestor (e.g. login-button ViewGroup).
  • Case-insensitive text matching for Android dialog buttons (textAllCaps displays uppercase but hierarchy text is mixed case).
  • hintText matching (hintContains / hintMatches): match an EditText's android:hint placeholder — closes a parity gap with Maestro.
  • Prepend clickable-first strategies in the text-tap fast path so .clickable(true) selectors actually get sent to the agent.
  • Simplify inputText without selector to SendKeyActions (drops the findFocused/ActiveElement fallback) — matches Maestro's "type into whatever the OS has focused" behavior.

runFlow

  • timeout: parameter on runFlow steps with context propagation into all drivers; mid-operation cancellation of polling loops; informative timeout errors categorized as TIMEOUT.

Misc fixes

  • Swipe LEFT/RIGHT on Android: use screen coordinates directly.
  • when: true: expression not parsed (evaluated truthy on wrong platforms).
  • Env var default syntax (${VAR || "default"}, ${VAR ?? "fallback"}) now resolved.

APK

  • Three devicelab-android-driver.apk refreshes, matching the on-device agent source changes (tracked in support-tools/devicelab-android-driver).

Test plan

  • Case-insensitive E2E (text-case-insensitive.yaml) passes 11/11 on Pixel 4a (Android 13, DeviceLab driver).
  • Auth suite (auth/) passes 4/4 on same device — no regressions.
  • go build ./... and go test ./pkg/executor/... ./pkg/flow/... green.
  • Full regression pass across UA2, Appium, and browser drivers.

Related

Resolve ${VAR || "default"} and ${VAR ?? "fallback"} patterns
matching Maestro's behavior. Previously, undefined variables caused
a JS ReferenceError that silently left the raw ${...} expression
in place.

Fixes #49, fixes #50
The Condition.Script yaml tag was "scriptCondition" (Maestro's internal
field name) instead of "true" (the actual YAML key users write). This
caused when: { true: ... } conditions to be silently ignored, making
them always evaluate to true.

Reported-by: Satyajit Sahoo <satyajit.happy@gmail.com> (@satya164)
Add case-insensitive textMatches/descriptionMatches as fallback after
the existing case-sensitive textContains/descriptionContains. Existing
behavior preserved — exact case match is tried first, case-insensitive
only kicks in when exact case fails.

Fixes Android dialog buttons where textAllCaps displays "CANCEL"
but the view hierarchy text is "Cancel".

TODO: DeviceLab driver agent (devicelab-android-driver) needs fixes:
  1. findByUiAutomator selector parsing order (textContains/textMatches
     checked before text to avoid prefix collision)
  2. Support .clickable(true) filter in UiSelector parsing
  Agent fix is in devicelab-android-driver repo but needs verification
  that the APK is reinstalled on device (may be cached).

Reported-by: Satyajit Sahoo <satyajit.happy@gmail.com> (@satya164)
Directional swipes without a selector now use screen coordinates
matching Maestro's behavior, instead of searching for a scrollable
element first. The scrollable detection was causing LEFT/RIGHT swipes
to fail when a vertical scrollable container was found.

Reported-by: Satyajit Sahoo <satyajit.happy@gmail.com> (@satya164)
Uses AccessibilityEvent listeners (WINDOW_CONTENT_CHANGED,
WINDOW_STATE_CHANGED, VIEW_SCROLLED) to detect when UI has settled.
Waits until no events fire for a quiet period (default 300ms),
with a configurable timeout (default 2000ms).

This is more reliable than hierarchy comparison for fast drivers
where the hierarchy fetch can complete before the UI starts changing.
Settle before steps that send keystrokes without implicit idle wait.
These steps fire immediately — if a screen transition is in progress
from a preceding tap, the input may arrive before the field has focus.

Uses DeviceLab's native event-based settle (AccessibilityEvent listener)
when available, falls back to hierarchy comparison for other drivers.
Skipped when waitForIdleTimeout is 0 (browser flows).
settleAfterAction now pierces driver wrappers via core.Unwrap so the
WaitForSettle type-assertion succeeds for DeviceLab and delegates to
the on-device agent's tree-comparison settle, which is faster and more
accurate than the hierarchy-fetch fallback.

Also removes the pre-input settle in flow_runner — settle is now
expected to run after tap/action steps, not before input steps, so the
fallback was firing twice and slowing flows.
Both Android drivers previously tried findFocused/ActiveElement and
then fell back to a focused=true selector search when no selector was
provided. Now we just send key events directly via SendKeyActions,
matching Maestro's "type into whatever the OS has focused" behavior.
Removes a round-trip and a fragile fallback path.
The case-insensitive textMatches fallback added in e7cdde4 works on
UIAutomator2 but breaks on the DeviceLab agent, whose findByUiAutomator
parser ignores chained filters like .clickable(true). With the filter
ignored, a textMatches regex that incidentally hit a non-clickable
wrapper/label returned that element instead of the intended button,
causing taps to miss.

UIAutomator2 and Appium paths are untouched — their case-insensitive
fixes continue to work. DeviceLab loses the Android dialog "CANCEL" vs
"Cancel" fix until the agent parser is taught to honor .clickable(true);
that follow-up will re-add these strategies.
Ships the findByUiAutomator fix so chained .clickable(true)/.enabled
filters on UiSelector are honored instead of silently ignored. Unblocks
re-adding case-insensitive text matching for DeviceLab.
Now that the agent's findByUiAutomator honors .clickable(true), the
case-insensitive textMatches/descriptionMatches fallback can be
re-enabled on the DeviceLab path without picking up non-clickable
wrappers. Restores the Android dialog "CANCEL" vs "Cancel" fix
(Login/login/LOGIN/lOgIn all match as contains).

This re-applies the DeviceLab half of e7cdde4 that was reverted in
b1c228a while the agent parser was broken.
The text-tap fast path used buildSelectors alone, which passes
preferClickable=false, so .clickable(true) selectors were never sent
to the agent. That meant the agent's ancestor-promotion logic could
not activate — text taps fell through to the first non-clickable
matching node (e.g. a subtitle TextView) and hit the wrong element.

Prepend buildClickableOnlyStrategies so clickable variants are tried
first. Combined with the agent's clickable-ancestor promotion,
"tapOn: SIGN IN" now hits the clickable login-button ViewGroup by
walking up from the "Sign In" TextView child inside it.
Adds hintContains and hintMatches strategies (case-sensitive and
case-insensitive) to buildClickableOnlyStrategies and
buildSelectorsWithOptions, alongside the existing text and
description chains. hintContains/hintMatches are DeviceLab-agent
extensions (not native UiSelector predicates) that match against
AccessibilityNodeInfo.getHintText(), so "tapOn: 'Email'" can find
an empty EditText by its android:hint placeholder.

Closes a parity gap with Maestro, whose text filter unions matches
across text, accessibility text, and hint text.
Ships the agent changes from devicelab-android-driver@bc76ac9:
clickable-ancestor promotion on .clickable(true) selectors, and
hintContains/hintMatches predicate support.
Add timeout parameter to runFlow steps that propagates through the
driver interface via SetContext, enabling mid-operation cancellation
of element-finding polling loops when the timeout expires.

Example usage:

  - runFlow:
      file: common/login.yaml
      timeout: 5000       # 5 second timeout
      env:
        username: devicelab

  - runFlow:
      timeout: 3000       # inline steps with timeout
      commands:
        - tapOn: "Submit"
        - assertVisible: "Success"

- Add SetContext(ctx) to Driver interface and all implementations
  (uiautomator2, wda, appium, devicelab, flutter, mock)
- Propagate timeout context into driver polling loops so element
  finding is interrupted immediately on expiry
- Fix parser to include timeout field in runFlow raw struct
- Replace cryptic "context deadline exceeded" errors with informative
  messages showing timeout value, flow file, and executing step
- Add enrichTimeoutError for sub-step error enrichment
- Timeout failures are classified as "TIMEOUT" error type in reports
  (not "UNKNOWN"), enabling proper failure categorization in HTML,
  JUnit, and Allure reports
- Add 4 executor tests and 2 parser tests for timeout behavior

Ref #29

Thanks to @maraujop for the suggestion!
Previously Provider only exposed ExtractMeta (post-Appium-session) and
ReportResult (post-run, post-reports). There was no way for a cloud
integration to observe the run as it progressed — test cases only
appeared in the dashboard after the entire run finished.

Adds three new lifecycle methods on Provider:

  OnRunStart(meta, totalFlows)                        — once, before first flow
  OnFlowStart(meta, flowIdx, totalFlows, name, file)  — before each flow
  OnFlowEnd(meta, *FlowResult)                        — after each flow

Errors from the new hooks log at Warn and do not abort the run, matching
ReportResult's behavior.

Sauce Labs and the example-provider template ship no-op implementations
(Sauce's dashboard renders per-test results from the final payload, so
there is no per-flow API to ping; future providers that do have live
per-case endpoints can override).

Wiring in pkg/cli/test.go:
- notifyCloudRunStart helper fires OnRunStart once per run, right
  before runner.Run(), across all three execution paths
  (executeSingleDevice, ExecuteFlowWithDriver, single-Appium)
- onFlowStartWithCloud and onFlowEndWithCloud closures wrap the
  existing console progress callbacks to also invoke OnFlowStart/End

Test coverage: TestProvider_Lifecycle_FiresInOrder asserts the full
ExtractMeta → OnRunStart → OnFlowStart → OnFlowEnd → ReportResult
sequence with per-flow ordering.
Reported by @ptmkenny in #54 — thank you for the clear repro.

The previous installIOSApp() called zipconduit.SendFile() unconditionally
on real devices, with no timeout. On iOS 17+ (and particularly iOS 26)
the install service sometimes accepts the USB-mux connection but never
acks completion, leaving SendFile blocked forever — the reporter saw
maestro-runner sit on "Installing app..." with no progress and no way
to recover.

Fix: prefer Apple's modern CoreDevice installer on real devices:

  xcrun devicectl device install app --device <UDID> <app-path>

Same command the reporter confirmed works on their iPhone 13 / iOS 26.

Fallback chain for real devices:
  1. xcrun devicectl device install app  (if devicectl is on PATH)
  2. go-ios zipconduit.SendFile          (legacy, older macOS / Xcode)

Both paths now run under a 3-minute context timeout so a stuck install
surfaces as an error instead of an infinite spinner. Simulator install
(xcrun simctl install) is also wrapped in the same timeout.

Escape hatch:
  MAESTRO_RUNNER_IOS_INSTALLER=zipconduit — force the legacy path
  MAESTRO_RUNNER_IOS_INSTALLER=devicectl  — force devicectl, no fallback

Verified on iPhone 12 mini running iOS 26.0: devicectl install completes
in seconds, replacing the previous hang.

Fixes #54
@omnarayan omnarayan merged commit ca480b3 into main Apr 20, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant