Skip to content

Commit 8a6f26d

Browse files
committed
refactor(maestro): collapse the duplicate scrollable-ancestor walk
fallow reported three structurally identical "walk up the parent chain to the nearest scrollable ancestor" implementations as clone groups (dup:1b401a24, dup:ce01e1de). Two of the three predicates classify identically, one does not. snapshot-policy.ts's isScrollableNode is logically identical to contracts' isScrollableNodeLike -- same six type patterns, same `=== 'table'` equality, same role/subrole fallback, and neither normalizes the type first. The walks match too, so findScrollableAncestorRect collapses onto findNearestScrollableAncestor with `(n) => Boolean(n.rect)`. runtime-port-geometry.ts's isScrollableSnapshotType does NOT agree. It equality-matches the NORMALIZED type, so over 227 node-type strings harvested from the repo's fixtures and tests it disagrees in both directions: Android ListView/GridView/RecyclerView, HorizontalScrollView, AXScrollBar and role-only scrollables clip but are not swipe containers, while XCUIElementTypeTable and AXTable are swipe containers but do not clip (the clip predicate compares 'table' against the unnormalized type, so prefixed forms miss). Only bare `table` satisfies both. It stays separate, with the divergence and the reason each call site needs its own answer written down where it can be read. The new test is load-bearing rather than decorative: replacing isScrollableSnapshotType with the contracts predicate leaves `pnpm maestro:conformance` at 46/46 and the pre-existing maestro suite at 206/206 green. The oracle does not cover scroll-container selection, so nothing else in the repo fails on that collapse. resolveRootViewport is deliberately left alone -- it resembles contracts' resolveViewportRect but lacks its third "largest containing rect of any node" fallback, so it is a real divergence and not the next dedup.
1 parent 74efcbb commit 8a6f26d

3 files changed

Lines changed: 183 additions & 38 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, test } from 'vitest';
2+
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
3+
import { resolveMaestroScrollableGesture } from '../runtime-port-geometry.ts';
4+
// The snapshot-facing platform union ('android' | 'ios'), not program-ir's,
5+
// which also carries 'web'.
6+
import type { MaestroPlatform } from '../runtime-target-policy.ts';
7+
import { isMaestroNodeVisible } from '../snapshot-policy.ts';
8+
9+
// Maestro asks two different questions about "is this node scrollable":
10+
//
11+
// clips - snapshot-policy.ts walks to the nearest scrollable ancestor to
12+
// decide which viewport a node is measured against (contracts'
13+
// isScrollableNodeLike: substring match on the RAW type, plus
14+
// role/subrole).
15+
// swipes - runtime-port-geometry.ts walks to the nearest scroll container to
16+
// decide where scrollUntilVisible starts its swipe
17+
// (isScrollableSnapshotType: equality match on the NORMALIZED type).
18+
//
19+
// The two predicates are structurally similar and were reported as clones, but
20+
// they classify differently in BOTH directions. This table is the agreed
21+
// answer for every type where they disagree; a "consolidation" that makes any
22+
// row's two columns equal changes which element a flow taps or swipes.
23+
//
24+
// The third walk (contracts' findNearestScrollableAncestor) is the one
25+
// snapshot-policy.ts now calls, so `clips` covers it too.
26+
27+
const APPLICATION: SnapshotNode = {
28+
index: 0,
29+
ref: '@e1',
30+
type: 'Application',
31+
visibleToUser: true,
32+
rect: { x: 0, y: 0, width: 402, height: 874 },
33+
};
34+
35+
// Tall and narrow so the vertical-axis check in selectMaestroScrollableViewport
36+
// accepts it, and short enough that the target below sits outside it.
37+
const CONTAINER_RECT = { x: 0, y: 100, width: 200, height: 600 };
38+
const TARGET_RECT = { x: 20, y: 750, width: 100, height: 40 };
39+
40+
function snapshotWithContainer(containerType: string) {
41+
const nodes: SnapshotNode[] = [
42+
APPLICATION,
43+
{
44+
index: 1,
45+
ref: '@e2',
46+
parentIndex: 0,
47+
type: containerType,
48+
visibleToUser: true,
49+
rect: CONTAINER_RECT,
50+
},
51+
{
52+
index: 2,
53+
ref: '@e3',
54+
parentIndex: 1,
55+
type: 'Button',
56+
identifier: 'target',
57+
visibleToUser: true,
58+
rect: TARGET_RECT,
59+
},
60+
];
61+
return { createdAt: 0, nodes };
62+
}
63+
64+
/** True when the container clips the target, i.e. it is the effective viewport. */
65+
function clips(containerType: string, platform: MaestroPlatform): boolean {
66+
const { nodes } = snapshotWithContainer(containerType);
67+
// The target sits outside the container rect but inside the Application rect,
68+
// so it reads as hidden exactly when the container is the viewport.
69+
return !isMaestroNodeVisible(nodes[2]!, nodes, platform);
70+
}
71+
72+
/** True when scrollUntilVisible swipes inside the container instead of the screen. */
73+
function swipes(containerType: string, platform: MaestroPlatform): boolean {
74+
const gesture = resolveMaestroScrollableGesture(
75+
snapshotWithContainer(containerType),
76+
{ id: 'target' },
77+
'down',
78+
600,
79+
platform,
80+
);
81+
// No container selected => daemon-runtime-port.ts falls back to a plain
82+
// screen scroll, which is the upstream-shaped gesture.
83+
if (!gesture) return false;
84+
expect(gesture.viewport).toEqual(CONTAINER_RECT);
85+
return true;
86+
}
87+
88+
describe('scrollable predicates agree on the canonical scroll types', () => {
89+
const cases: ReadonlyArray<[string, MaestroPlatform, boolean]> = [
90+
['XCUIElementTypeScrollView', 'ios', true],
91+
['XCUIElementTypeCollectionView', 'ios', true],
92+
['AXScrollArea', 'ios', true],
93+
['android.widget.ScrollView', 'android', true],
94+
['ScrollView', 'android', true],
95+
['XCUIElementTypeButton', 'ios', false],
96+
['android.widget.LinearLayout', 'android', false],
97+
];
98+
99+
test.each(cases)('%s on %s is scrollable to both walks: %s', (type, platform, scrollable) => {
100+
expect(clips(type, platform)).toBe(scrollable);
101+
expect(swipes(type, platform)).toBe(scrollable);
102+
});
103+
});
104+
105+
describe('scrollable predicates diverge, and each answer is the intended one', () => {
106+
// Substring match on the raw type catches these; equality on the normalized
107+
// type does not. They clip (a nested list really does bound what you can
108+
// see) but do not capture the swipe, which falls back to a screen scroll.
109+
const clipsOnly: ReadonlyArray<[string, MaestroPlatform]> = [
110+
['android.widget.ListView', 'android'],
111+
['android.widget.GridView', 'android'],
112+
['androidx.recyclerview.widget.RecyclerView', 'android'],
113+
['RecyclerView', 'android'],
114+
['android.widget.HorizontalScrollView', 'android'],
115+
['AXScrollBar', 'ios'],
116+
];
117+
118+
test.each(clipsOnly)('%s on %s clips but is not a swipe container', (type, platform) => {
119+
expect(clips(type, platform)).toBe(true);
120+
expect(swipes(type, platform)).toBe(false);
121+
});
122+
123+
// The mirror image: normalizing strips the XCUIElementType/AX prefix, so
124+
// these reach the `=== 'table'` arm of the swipe predicate. The clip
125+
// predicate compares 'table' against the UNNORMALIZED type, so the prefixed
126+
// forms miss it and the target is measured against the root viewport.
127+
const swipesOnly: ReadonlyArray<[string, MaestroPlatform]> = [
128+
['XCUIElementTypeTable', 'ios'],
129+
['AXTable', 'ios'],
130+
];
131+
132+
test.each(swipesOnly)('%s on %s is a swipe container but does not clip', (type, platform) => {
133+
expect(clips(type, platform)).toBe(false);
134+
expect(swipes(type, platform)).toBe(true);
135+
});
136+
137+
// Bare 'table' is the one spelling both predicates accept, which is why the
138+
// divergence above is easy to miss when reading either one alone.
139+
test('bare table is accepted by both', () => {
140+
expect(clips('table', 'ios')).toBe(true);
141+
expect(swipes('table', 'ios')).toBe(true);
142+
});
143+
144+
// Only the clip predicate looks at role/subrole at all.
145+
test('role-only scrollables clip but are not swipe containers', () => {
146+
const { nodes } = snapshotWithContainer('Other');
147+
const container = { ...nodes[1]!, role: 'AXScrollArea' };
148+
const withRole = [nodes[0]!, container, nodes[2]!];
149+
expect(isMaestroNodeVisible(withRole[2]!, withRole, 'ios')).toBe(false);
150+
expect(
151+
resolveMaestroScrollableGesture(
152+
{ createdAt: 0, nodes: withRole },
153+
{ id: 'target' },
154+
'down',
155+
600,
156+
'ios',
157+
),
158+
).toBeUndefined();
159+
});
160+
});

packages/maestro/src/internal/runtime-port-geometry.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,25 @@ function findLargestViewportRect(nodes: SnapshotState['nodes']): Rect | undefine
105105
)[0]?.rect;
106106
}
107107

108+
// NOT interchangeable with contracts' isScrollableNodeLike, which the
109+
// visibility walk in snapshot-policy.ts uses. That one substring-matches the
110+
// raw type; this one exact-matches the normalized type, so they disagree in
111+
// both directions:
112+
// android.widget.{ListView,GridView,RecyclerView}, HorizontalScrollView,
113+
// AXScrollBar, and nodes scrollable only via role/subrole
114+
// -> scrollable for visibility, not a scroll container here
115+
// XCUIElementTypeTable, AXTable
116+
// -> scroll container here, not scrollable for visibility (there 'table'
117+
// is an equality test against the unnormalized type, so prefixed forms
118+
// miss)
119+
// The two questions are not the same, and the failure modes are asymmetric.
120+
// Visibility asks "does an ancestor clip me": over-matching only shrinks a
121+
// viewport. This asks "which container does scrollUntilVisible swipe inside":
122+
// over-matching starts the swipe in the wrong element, while under-matching
123+
// yields no viewport and daemon-runtime-port.ts falls back to a plain screen
124+
// scroll. Widening this predicate therefore trades a safe fallback for a
125+
// silent mis-aimed gesture and needs its own conformance evidence.
126+
// Pinned by __tests__/scrollable-predicate-divergence.test.ts.
108127
function isScrollableSnapshotType(type: string | undefined): boolean {
109128
const normalized = normalizeType(type ?? '');
110129
return (

packages/maestro/src/internal/snapshot-policy.ts

Lines changed: 4 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
21
import {
32
buildSnapshotNodeMap,
3+
findNearestScrollableAncestor,
44
findSnapshotAncestor,
55
isUsefulVisibilityAnchor,
66
} from '@agent-device/contracts/snapshot';
7+
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
78
import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot';
89

910
export function isMaestroNodeVisible(
@@ -44,46 +45,11 @@ function isVisibleInEffectiveViewport(node: SnapshotNode, nodes: SnapshotNode[])
4445
if (!node.rect) return true;
4546
const byIndex = buildSnapshotNodeMap(nodes);
4647
const viewport =
47-
findScrollableAncestorRect(node, byIndex) ?? resolveRootViewport(nodes, node.rect);
48+
findNearestScrollableAncestor(node, byIndex, (ancestor) => Boolean(ancestor.rect))?.rect ??
49+
resolveRootViewport(nodes, node.rect);
4850
return viewport ? rectsOverlap(node.rect, viewport) : true;
4951
}
5052

51-
// Structurally the same parent walk as `@agent-device/contracts/snapshot`'s
52-
// `findNearestScrollableAncestor` and `runtime-port-geometry.ts`'s
53-
// `findNearestScrollableContainer`, but each uses a DIFFERENT scrollable
54-
// predicate, and the three have not been shown to agree. Collapsing them is a
55-
// Maestro-conformance change, not a cleanup — left alone deliberately.
56-
function findScrollableAncestorRect(
57-
node: SnapshotNode,
58-
byIndex: ReadonlyMap<number, SnapshotNode>,
59-
): Rect | null {
60-
let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined;
61-
const visited = new Set<number>();
62-
while (current && !visited.has(current.index)) {
63-
visited.add(current.index);
64-
if (current.rect && isScrollableNode(current)) return current.rect;
65-
current =
66-
typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined;
67-
}
68-
return null;
69-
}
70-
71-
// fallow-ignore-next-line complexity
72-
function isScrollableNode(node: SnapshotNode): boolean {
73-
const type = `${node.type ?? ''}`.toLowerCase();
74-
if (
75-
type.includes('scroll') ||
76-
type.includes('recyclerview') ||
77-
type.includes('listview') ||
78-
type.includes('gridview') ||
79-
type.includes('collectionview') ||
80-
type === 'table'
81-
) {
82-
return true;
83-
}
84-
return `${node.role ?? ''} ${node.subrole ?? ''}`.toLowerCase().includes('scroll');
85-
}
86-
8753
function resolveRootViewport(nodes: SnapshotNode[], target: Rect): Rect | null {
8854
const viewportRects = nodes
8955
.filter((node) => {

0 commit comments

Comments
 (0)