Repository navigation
Android has no viewport-root node, so two guards are silently dead #1609
Description
Activity
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:
- 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-agentand is suitable for Luna. - 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-agentuntil 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
isScrollingContainerwithisScrollableNodeLike. 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.
- 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
Verified on current
origin/main70fb7837293816e743c778b0b4ae2c49a084f829after #2122 and #2156 merged.Live evidence:
- Device: dedicated
Pixel_7_CIAVD, Android API 36, serialemulator-5556. The occupiedPixel_7_reviewAVD and its owning session were left untouched. - Freshness: ran
pnpm install --frozen-lockfile,pnpm build,pnpm build:android, andpnpm clean:daemonbefore opening the device. - Capture: Settings used
androidSnapshot.backend = android-helper,helperVersion = 0.20.11-dev(matchingpackage.json),helperApiVersion = 2, with healthy snapshot quality and no truncation. - Rootless contract shape: the emitted regular tree contained no
ApplicationorWindownode. Its root wasandroid.widget.ScrollView(1080x2337), withmain_content_scrollable_container -> homepage_container -> androidx.recyclerview.widget.RecyclerView -> row LinearLayout -> TextView. - Action proof: clicked the nested
TextViewref 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#isScrollingContainervocabulary 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.- Device: dedicated
Summary
Android snapshots contain no
Application/Windownode, 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 returnnullon 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/Windowappear asnode.typeonly in test fixtures. Production Android emits the uiautomatorclassattribute 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:Only
packages/contracts/src/snapshot-visibility.ts#resolveViewportRecthas the third fallback ("largest containing rect of any node"), which is the only arm that can return on Android.Consequences
isOverlyBroadAncestoris dead on Android.src/core/interaction-targeting.ts:138—resolveRootViewportRectreturnsnull, the function early-returnsfalse, and the viewport-sized-ancestor guard never fires. Reached fromresolveActionableTouchResolution, which is not platform-gated.isMaestroNodeVisiblehas no on-screen check on Android.packages/maestro/src/internal/snapshot-policy.ts:44—resolveRootViewportreturnsnull, soisVisibleInEffectiveViewportreturnstruefor 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#isVisibleInEffectiveViewportis a clone ofcontracts#isNodeVisibleInEffectiveViewport. It cannot be deleted independently: its only caller ofresolveRootViewportis that function, so removing the clone is the resolver swap above.isMaestroNodeVisiblerebuildsbuildSnapshotNodeMap(nodes)per node (twice: once in the ancestor walk, once in the visibility check) andresolveRootViewportre-scans and re-sorts per node, whilefilterVisibleMaestroMatchescalls it in a.filter(). O(N·M) on trees where N is thousands. The contracts twin takesbyIndexas 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 canonicalisScrollableNodeLikeover emitted vocabulary it disagrees on AndroidGridView(canonicaltrue, this onefalse) and macOSAXList/AXTable(falsevstrue) andAXScrollBar(truevsfalse).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
contracts/snapshot-visibility.ts; the other two call it.byIndexthrough so the per-node map rebuilds go.isScrollingContaineratisScrollableNodeLike.main.scrollUntilVisiblecontainer selection before/after.