Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/contracts/src/facades/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
isNodeVisibleInEffectiveViewport,
isNodeVisibleOnScreen,
isUsefulVisibilityAnchor,
isViewportRootNode,
isTapPointInsideViewport,
resolveEffectiveViewportRect,
resolveViewportRect,
Expand Down
9 changes: 2 additions & 7 deletions packages/contracts/src/scroll-gesture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AppError } from '@agent-device/kernel/errors';
import { defineStringEnum } from './string-enum.ts';
import { isViewportRootNode } from './snapshot-visibility.ts';
import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot';

// What a caller may ASK for, as opposed to `ScrollDirection` (what the gesture resolves to):
Expand Down Expand Up @@ -273,7 +274,7 @@ export function parseScrollDirection(direction: string): ScrollDirection {

function inferViewportRect(nodes: Array<Pick<SnapshotNode, 'type' | 'rect'>>): Rect | undefined {
const candidate = nodes
.filter((node) => isViewportNode(node.type) && isValidRect(node.rect))
.filter((node) => isViewportRootNode(node) && isValidRect(node.rect))
.map((node) => node.rect)
.sort(
(left, right) =>
Expand All @@ -290,12 +291,6 @@ function inferViewportRect(nodes: Array<Pick<SnapshotNode, 'type' | 'rect'>>): R
return { x: 0, y: 0, width, height };
}

function isViewportNode(type: string | undefined): boolean {
if (!type) return false;
const normalized = type.toLowerCase();
return normalized.includes('application') || normalized.includes('window');
}

function isValidRect(rect: Rect | undefined): rect is Rect {
return !!rect && rect.width > 0 && rect.height > 0;
}
Expand Down
219 changes: 219 additions & 0 deletions packages/contracts/src/snapshot-viewport-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, test } from 'vitest';

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
import { isViewportRootNode } from './snapshot-visibility.ts';

/**
* `isViewportRootNode` pinned over the vocabulary each backend ACTUALLY emits
* into `node.type`/`role`/`subrole` — not over fixture strings.
*
* Nine hand-rolled spellings of this predicate existed before #1592's method was
* applied here: three normalizing `type|role|subrole`, five lowercasing or
* normalizing `type` alone, and one comparing the normalized type for EQUALITY.
* Measured over the 75 names in the three tables below they agree on 71. All
* four disagreements are macOS windows, and they split two ways:
*
* - `AXFloatingWindow` / `AXSystemFloatingWindow`: only the `===` spelling
* (maestro) misses them, and its platform union is `android | ios`, so it
* can never see a macOS node. Inert — the same shape #1592 found for its
* own role/subrole arm.
* - `AXSystemDialog` / `AXUnknown`: an `AXWindow` whose subrole is emitted AS
* the type, so the six type-only spellings cannot see they are windows while
* `role` names it exactly. These two are the entire behavioral delta of the
* consolidation, and they are windows by role.
*
* Grounding this in emitted vocabulary is what made the collapse provable; a
* fixture-string comparison would have reported divergences no backend can
* produce (#1592, second commit).
*
* Keep these tables honest against their emitters — a predicate that silently
* stops matching the root is invisible in every other test, because the root
* node is scenery in all of them. The macOS table asserts its own completeness
* against the emitter's fixed-output set below; iOS and Android do not, so a
* new `elementTypeName` case or a new Android container class still has to be
* added here by hand.
*/

// apple/runner/.../RunnerTests+Snapshot.swift `elementTypeName` — a closed set.
// `Element(N)` is the escape hatch for element types the switch does not name.
const IOS_EMITTED_TYPES = [
'Application',
'Window',
'Button',
'Cell',
'StaticText',
'TextField',
'TextView',
'SecureTextField',
'Switch',
'Slider',
'Link',
'Image',
'NavigationBar',
'TabBar',
'CollectionView',
'Table',
'ScrollView',
'Toolbar',
'SearchField',
'SegmentedControl',
'Stepper',
'Picker',
'ActivityIndicator',
'ProgressIndicator',
'CheckBox',
'MenuItem',
'WebView',
'Other',
'Keyboard',
'Key',
'Element(72)',
] as const;

// src/platforms/android/ui-hierarchy.ts — `type` is the uiautomator `class`
// attribute verbatim, a fully-qualified Java class name.
const ANDROID_EMITTED_TYPES = [
'android.view.View',
'android.view.ViewGroup',
'android.widget.Button',
'android.widget.EditText',
'android.widget.FrameLayout',
'android.widget.HorizontalScrollView',
'android.widget.ImageButton',
'android.widget.ImageView',
'android.widget.LinearLayout',
'android.widget.ScrollView',
'android.widget.SeekBar',
'android.widget.Switch',
'android.widget.TextView',
'androidx.compose.ui.platform.ComposeView',
'androidx.recyclerview.widget.RecyclerView',
'android.widget.ListView',
'android.widget.GridView',
'androidx.core.widget.NestedScrollView',
] as const;

// apple/macos-helper/.../SnapshotTraversal.swift `normalizedSnapshotType`. Three
// output classes, all represented below:
// 1. every one of the 13 roles the switch maps to a fixed short name;
// 2. `AXWindow`, whose output is the SUBROLE unless that subrole is
// `AXStandardWindow` — so a window's `type` can be any subrole string;
// 3. the `default:` arm, `subrole ?? role`, which emits the raw `AX`-prefixed
// value for every unmapped role.
// This backend is the only one that populates `role`/`subrole` at all.
const MACOS_EMITTED_NODES = [
// 1. the complete fixed-output set.
{ type: 'Application', role: 'AXApplication' },
{ type: 'Sheet', role: 'AXSheet' },
{ type: 'Dialog', role: 'AXDialog' },
{ type: 'Button', role: 'AXButton' },
{ type: 'StaticText', role: 'AXStaticText' },
{ type: 'TextField', role: 'AXTextField' },
{ type: 'TextArea', role: 'AXTextArea' },
{ type: 'ScrollArea', role: 'AXScrollArea' },
{ type: 'Group', role: 'AXGroup' },
{ type: 'MenuBar', role: 'AXMenuBar' },
{ type: 'MenuBarItem', role: 'AXMenuBarItem' },
{ type: 'Menu', role: 'AXMenu' },
{ type: 'MenuItem', role: 'AXMenuItem' },
// 2. AXWindow: standard subrole collapses to "Window", every other subrole
// is emitted verbatim as the type.
{ type: 'Window', role: 'AXWindow', subrole: 'AXStandardWindow' },
{ type: 'AXFloatingWindow', role: 'AXWindow', subrole: 'AXFloatingWindow' },
{ type: 'AXSystemFloatingWindow', role: 'AXWindow', subrole: 'AXSystemFloatingWindow' },
{ type: 'AXSystemDialog', role: 'AXWindow', subrole: 'AXSystemDialog' },
{ type: 'AXUnknown', role: 'AXWindow', subrole: 'AXUnknown' },
// 3. the raw `subrole ?? role` fallback for unmapped roles.
{ type: 'AXWebArea', role: 'AXWebArea' },
{ type: 'AXList', role: 'AXList' },
{ type: 'AXTable', role: 'AXTable' },
{ type: 'AXOutline', role: 'AXOutline' },
{ type: 'AXScrollBar', role: 'AXScrollBar' },
{ type: 'AXSplitGroup', role: 'AXSplitGroup' },
{ type: 'AXToolbar', role: 'AXToolbar' },
// A subrole on an UNMAPPED role: the default arm returns `subrole ?? role`, so the
// subrole wins. (`AXTextField` cannot appear here — its own arm always returns
// `TextField`, whatever the subrole.)
{ type: 'AXSortButton', role: 'AXCell', subrole: 'AXSortButton' },
] as const;

/**
* The emitter's fixed outputs, READ FROM THE EMITTER — not a second hand-kept
* list. A hand-kept twin compared against a hand-kept table is circular: both
* drift together and the comparison stays green (#1613 review). Parsing the
* Swift switch is what makes "a role added there fails here" a real claim.
*/
function macosFixedOutputsFromEmitter(): string[] {
const swift = fs.readFileSync(
path.join(
REPO_ROOT,
'apple/macos-helper/Sources/AgentDeviceMacOSHelper/SnapshotTraversal.swift',
),
'utf8',
);
const fn = /private func normalizedSnapshotType\([\s\S]*?\n\}/.exec(swift)?.[0];
if (!fn) throw new Error('normalizedSnapshotType not found — the emitter moved; fix this parser');
// `case "AXWindow":` returns a subrole expression, not a literal, so it is
// deliberately absent from the literal-return set the table pins.
return [...fn.matchAll(/return "([A-Za-z]+)"/g)].map((match) => match[1]!).sort();
}

describe('isViewportRootNode over emitted backend vocabulary', () => {
test('iOS: exactly Application and Window, out of 31 emitted names', () => {
const roots = IOS_EMITTED_TYPES.filter((type) => isViewportRootNode({ type }));
expect(roots).toEqual(['Application', 'Window']);
});

test('iOS: substring and equality agree, so the collapse of the `===` spelling was a no-op', () => {
for (const type of IOS_EMITTED_TYPES) {
const equality = type === 'Application' || type === 'Window';
expect({ type, root: isViewportRootNode({ type }) }).toEqual({ type, root: equality });
}
});

// The load-bearing one. Android has no root node, so `resolveViewportRect`'s
// third fallback (largest containing rect of any node) is the only arm that
// ever returns on Android — and the resolvers that lack it return null there.
test('Android: no emitted class name is a viewport root', () => {
const roots = ANDROID_EMITTED_TYPES.filter((type) => isViewportRootNode({ type }));
expect(roots).toEqual([]);
});

test('macOS: every AXWindow subrole is a root, whatever `type` says', () => {
const roots = MACOS_EMITTED_NODES.filter(isViewportRootNode).map((node) => node.type);
expect(roots).toEqual([
'Application',
'Window',
'AXFloatingWindow',
'AXSystemFloatingWindow',
'AXSystemDialog',
'AXUnknown',
]);
});

// The table's own completeness claim, asserted rather than trusted: every
// fixed output the emitter's switch can return appears above, so adding a
// role to that switch without adding it here fails.
test('macOS: the table covers every fixed output the Swift emitter can return', () => {
const covered = new Set<string>(MACOS_EMITTED_NODES.map((node) => node.type));
const emitted = macosFixedOutputsFromEmitter();
expect(emitted.length).toBeGreaterThan(10);
expect(emitted.filter((name) => !covered.has(name))).toEqual([]);
});

// Why the canonical predicate reads role/subrole and not `type` alone: these
// two are windows that no type-only spelling could see.
test('macOS: role rescues the two window subroles `type` alone cannot name', () => {
const typeOnly = (type: string) => {
const value = type.toLowerCase();
return value.includes('application') || value.includes('window');
};
const rescued = MACOS_EMITTED_NODES.filter(
(node) => isViewportRootNode(node) && !typeOnly(node.type),
).map((node) => node.type);
expect(rescued).toEqual(['AXSystemDialog', 'AXUnknown']);
});
});
57 changes: 36 additions & 21 deletions packages/contracts/src/snapshot-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,52 @@ type SnapshotVisibilityNode = Pick<
'rect' | 'index' | 'parentIndex' | 'type' | 'role' | 'subrole'
>;

/**
* The application/window root: the node a target rect is measured against, and
* the node whose own rect is invariant under any gesture.
*
* One definition for the whole repo. It reads `type`, `role` AND `subrole`
* because the macOS helper is the only backend that populates the latter two,
* and it is the only backend that can emit a window whose `type` does not say
* so — `normalizedSnapshotType` returns the raw subrole for a non-standard
* window, so an `AXWindow` with subrole `AXSystemDialog` or `AXUnknown` reads
* as neither from `type` alone while `role` names it exactly.
*
* Substring, not equality: macOS emits unmapped roles with their `AX` prefix
* intact and subroles like `AXFloatingWindow` that are windows by any reading.
* iOS emits a closed set of 31 short names in which only `Application` and
* `Window` contain either word, so substring and equality agree there. Android
* emits fully-qualified Java class names and no root node at all, so no
* spelling of this predicate matches anything on Android — see
* `resolveViewportRect`'s third fallback, which is what Android actually uses.
*/
export function isViewportRootNode(node: Pick<SnapshotNode, 'type' | 'role' | 'subrole'>): boolean {
const kind = [node.type, node.role, node.subrole]
.map((value) => normalizeType(value ?? ''))
.join(' ');
return kind.includes('application') || kind.includes('window');
}

/**
* The root viewport a target rect is measured against: the largest
* Application/Window rect containing the target's center, falling back to the
* largest such rect, then to the largest containing rect of any node.
*/
export function resolveViewportRect(nodes: RawSnapshotNode[], targetRect: Rect): Rect | null {
const targetCenter = centerOfRect(targetRect);
const rectNodes = nodes.filter((node) => hasValidRect(node.rect));
const viewportNodes = rectNodes.filter((node) => {
const type = (node.type ?? '').toLowerCase();
return type.includes('application') || type.includes('window');
});

const containingViewport = pickLargestRect(
viewportNodes
.map((node) => node.rect as Rect)
.filter((rect) => containsPoint(rect, targetCenter.x, targetCenter.y)),
const rects = nodes.flatMap((node) =>
hasValidRect(node.rect) ? [{ node, rect: node.rect }] : [],
);
if (containingViewport) return containingViewport;

const viewportFallback = pickLargestRect(viewportNodes.map((node) => node.rect as Rect));
if (viewportFallback) return viewportFallback;
const viewportRects = rects
.filter((entry) => isViewportRootNode(entry.node))
.map((entry) => entry.rect);
const contains = (rect: Rect) => containsPoint(rect, targetCenter.x, targetCenter.y);

const genericContaining = pickLargestRect(
rectNodes
.map((node) => node.rect as Rect)
.filter((rect) => containsPoint(rect, targetCenter.x, targetCenter.y)),
return (
pickLargestRect(viewportRects.filter(contains)) ??
pickLargestRect(viewportRects) ??
pickLargestRect(rects.map((entry) => entry.rect).filter(contains))
);
if (genericContaining) return genericContaining;

return null;
}

function hasValidRect(rect: Rect | undefined): rect is Rect {
Expand Down
5 changes: 2 additions & 3 deletions packages/maestro/src/internal/runtime-port-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { pointInsideRect } from './shared.ts';
import {
findNearestScrollableAncestor,
isScrollableNodeLike,
normalizeType,
isViewportRootNode,
} from '@agent-device/contracts/snapshot';
import { MAESTRO_COMPATIBILITY_PRESETS } from './compatibility-policy.ts';
import { resolveNumeric } from './engine-flow.ts';
Expand Down Expand Up @@ -108,8 +108,7 @@ function findScrollContainer(
function findLargestViewportRect(nodes: SnapshotState['nodes']): Rect | undefined {
return nodes
.filter((node) => {
const type = normalizeType(node.type ?? '');
return isPositiveFiniteRect(node.rect) && (type === 'application' || type === 'window');
return isPositiveFiniteRect(node.rect) && isViewportRootNode(node);
})
.sort(
(left, right) =>
Expand Down
25 changes: 8 additions & 17 deletions packages/maestro/src/internal/snapshot-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import {
findNearestScrollableAncestor,
findSnapshotAncestor,
isUsefulVisibilityAnchor,
isViewportRootNode,
} from '@agent-device/contracts/snapshot';
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
import {
containsPoint,
isPositiveFiniteRect,
isRectVisibleInViewport,
} from '@agent-device/kernel/rect';
import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot';

export function isMaestroNodeVisible(
Expand Down Expand Up @@ -47,15 +52,12 @@ function isVisibleInEffectiveViewport(node: SnapshotNode, nodes: SnapshotNode[])
const viewport =
findNearestScrollableAncestor(node, byIndex, (ancestor) => Boolean(ancestor.rect))?.rect ??
resolveRootViewport(nodes, node.rect);
return viewport ? rectsOverlap(node.rect, viewport) : true;
return viewport ? isRectVisibleInViewport(node.rect, viewport) : true;
}

function resolveRootViewport(nodes: SnapshotNode[], target: Rect): Rect | null {
const viewportRects = nodes
.filter((node) => {
const type = (node.type ?? '').toLowerCase();
return node.rect && (type.includes('application') || type.includes('window'));
})
.filter((node) => node.rect && isViewportRootNode(node))
.map((node) => node.rect!)
.sort((left, right) => right.width * right.height - left.width * left.height);
const centerX = target.x + target.width / 2;
Expand All @@ -64,14 +66,3 @@ function resolveRootViewport(nodes: SnapshotNode[], target: Rect): Rect | null {
viewportRects.find((rect) => containsPoint(rect, centerX, centerY)) ?? viewportRects[0] ?? null
);
}

function rectsOverlap(left: Rect, right: Rect): boolean {
return (
Math.max(left.x, right.x) <= Math.min(left.x + left.width, right.x + right.width) &&
Math.max(left.y, right.y) <= Math.min(left.y + left.height, right.y + right.height)
);
}

function containsPoint(rect: Rect, x: number, y: number): boolean {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
Loading
Loading