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.ts → type: 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 (FrameLayout → RecyclerView → TextView, 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:138 — resolveRootViewportRect 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:44 — resolveRootViewport 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.

Activity

  1. thymikee commented on Aug 28, 2026

    @thymikee
    MemberAuthor

    Execution split after current-main re-audit (2026-08-28): this issue is now the umbrella and is no longer AFK-ready as one unit.

    Ordered children:

    1. Android/core: use the canonical viewport fallback for rootless touch-target trees #2120 — core actionable-touch targeting consumes the canonical viewport fallback for every rootless tree. This is the only child currently ready-for-agent and is suitable for Luna.
    2. Maestro: consume canonical rootless viewport visibility with reusable context #2121 — Maestro consumes canonical rootless visibility and reuses one visibility context per filtering operation. Blocked by Android/core: use the canonical viewport fallback for rootless touch-target trees #2120; keep it out of ready-for-agent until the prerequisite merges and the scope is rebased.

    Decided semantic boundary: rootless fallback is universal. Consumers must not infer Android from Java class names, backend names, or other strings. A future platform exception must arrive as typed capture evidence at the owning contract.

    Deferred from both children: consolidating core isScrollingContainer with isScrollableNodeLike. Their emitted per-platform vocabularies currently differ, so that cleanup needs its own parity characterization and must not ride along with the correctness fixes.

    PR #1830 remains useful only as an anti-pattern: do not revive its Android class-name heuristic, duplicated property arbitrary, or per-candidate context construction.

  2. thymikee commented on Aug 31, 2026

    @thymikee
    MemberAuthor

    Verified on current origin/main 70fb7837293816e743c778b0b4ae2c49a084f829 after #2122 and #2156 merged.

    Live evidence:

    • Device: dedicated Pixel_7_CI AVD, Android API 36, serial emulator-5556. The occupied Pixel_7_review AVD and its owning session were left untouched.
    • Freshness: ran pnpm install --frozen-lockfile, pnpm build, pnpm build:android, and pnpm clean:daemon before opening the device.
    • Capture: Settings used androidSnapshot.backend = android-helper, helperVersion = 0.20.11-dev (matching package.json), helperApiVersion = 2, with healthy snapshot quality and no truncation.
    • Rootless contract shape: the emitted regular tree contained no Application or Window node. Its root was android.widget.ScrollView (1080x2337), with main_content_scrollable_container -> homepage_container -> androidx.recyclerview.widget.RecyclerView -> row LinearLayout -> TextView.
    • Action proof: clicked the nested TextView ref for Network & internet. The action resolved to the semantic row center (540,739), not the outer RecyclerView/full-screen structural ancestor, and returned success for the original ref/label. A post-action Android-helper snapshot showed the expected Network & internet page (Internet, SIMs, Airplane mode, etc.).

    The isolated agent-device session was closed and only the CI AVD started for this check was shut down afterward.

    This closes the remaining live-evidence gap from #2122. The two defects named here are implemented by #2122 (core actionable-touch viewport fallback) and #2156 (canonical Maestro visibility/context), with exact-head CI green.

    The local src/core/interaction-targeting.ts#isScrollingContainer vocabulary difference was explicitly excluded from both child issues because it asks a separate semantic question across platform vocabularies. It is not evidence that either viewport-root guard remains dead and should be characterized independently if pursued.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions