You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.typeonly 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
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.
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
One resolver in contracts/snapshot-visibility.ts; the other two call it.
Delete maestro's cloned visibility function; pass byIndex through so the per-node map rebuilds go.
Point isScrollingContainer at isScrollableNodeLike.
Android regression tests asserting each guard now fires, proven red against main.
Emulator validation on a RecyclerView-backed list: tap retargeting and scrollUntilVisible container selection before/after.
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.