Skip to content

Android has no viewport-root node, so two guards are silently dead #1609

Description

@thymikee

Summary

Android snapshots contain no Application/Window node, so every "resolve the root viewport" path falls through to its fallbacks — and the repo has three such resolvers with different fallbacks. Two of them return null on Android, which silently disables two guards.

Found while collapsing the nine viewport-root predicates onto one definition (that part is safe and shipped separately). The predicates were textual duplication; the resolvers are a real divergence — the one #1592 parked as "resolveRootViewport is deliberately left alone … a real divergence and not the next dedup." Measurement says it is a defect, the same way #1592's second commit found its own divergence note was covering a bug.

Evidence

Application / Window appear as node.type only in test fixtures. Production Android emits the uiautomator class attribute verbatim (src/platforms/android/ui-hierarchy.tstype: attrs.className), i.e. fully-qualified Java class names. No spelling of the viewport-root predicate matches any of them.

Running the three resolvers over an Android-shaped tree (FrameLayoutRecyclerViewTextView, screen 1080×2400) versus an iOS-shaped one:

=== android tree, resolving the viewport for a row at y=300 ===
  contracts  resolveViewportRect      : {"x":0,"y":0,"width":1080,"height":2400}
  interaction-targeting resolveRoot…  : null
  maestro    resolveRootViewport      : null

=== ios tree ===
  all three                           : {"x":0,"y":0,"width":1080,"height":2400}

Only packages/contracts/src/snapshot-visibility.ts#resolveViewportRect has the third fallback ("largest containing rect of any node"), which is the only arm that can return on Android.

Consequences

  1. isOverlyBroadAncestor is dead on Android. src/core/interaction-targeting.ts:138resolveRootViewportRect returns null, the function early-returns false, and the viewport-sized-ancestor guard never fires. Reached from resolveActionableTouchResolution, which is not platform-gated.
  2. isMaestroNodeVisible has no on-screen check on Android. packages/maestro/src/internal/snapshot-policy.ts:44resolveRootViewport returns null, so isVisibleInEffectiveViewport returns true for every node without a scrollable ancestor.

Invisible in tests because the viewport-root concept is only ever exercised with iOS-shaped fixtures, where the root node is scenery.

Adjacent, same sweep

  • packages/maestro/src/internal/snapshot-policy.ts#isVisibleInEffectiveViewport is a clone of contracts#isNodeVisibleInEffectiveViewport. It cannot be deleted independently: its only caller of resolveRootViewport is that function, so removing the clone is the resolver swap above.
  • isMaestroNodeVisible rebuilds buildSnapshotNodeMap(nodes) per node (twice: once in the ancestor walk, once in the visibility check) and resolveRootViewport re-scans and re-sorts per node, while filterVisibleMaestroMatches calls it in a .filter(). O(N·M) on trees where N is thousands. The contracts twin takes byIndex as a parameter, so unifying fixes this for free.
  • src/core/interaction-targeting.ts:144#isScrollingContainer (added in fix(ios): harden Bluesky-class interaction reliability #1588) is a tenth scroll predicate. Measured against canonical isScrollableNodeLike over emitted vocabulary it disagrees on Android GridView (canonical true, this one false) and macOS AXList/AXTable (false vs true) and AXScrollBar (true vs false).

Why this is not a drive-by

Fixing it turns on two guards that have never fired on Android. That is a real behavior change for Android users, on paths (tap retargeting, Maestro visibility filtering) where a newly-active guard can change which element gets hit. Needs emulator validation, not fixture validation — per AGENTS.md, a guard that has never fired is exactly what fixtures cannot cover.

Suggested shape

  1. One resolver in contracts/snapshot-visibility.ts; the other two call it.
  2. Delete maestro's cloned visibility function; pass byIndex through so the per-node map rebuilds go.
  3. Point isScrollingContainer at isScrollableNodeLike.
  4. Android regression tests asserting each guard now fires, proven red against main.
  5. Emulator validation on a RecyclerView-backed list: tap retargeting and scrollUntilVisible container selection before/after.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions