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
24 changes: 24 additions & 0 deletions src/daemon/__tests__/snapshot-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { expect, test } from 'vitest';
import { buildSnapshotState } from '../snapshot-state.ts';
import { isNodeVisibleOnScreen } from '@agent-device/contracts/snapshot';
import {
buildUiHierarchySnapshot,
parseUiHierarchyTree,
} from '../../platforms/android/ui-hierarchy.ts';

test('buildSnapshotState handles undefined nodes gracefully', () => {
const state = buildSnapshotState({ nodes: undefined, truncated: undefined }, undefined);
Expand Down Expand Up @@ -37,6 +42,25 @@ test('buildSnapshotState carries structured snapshot quality verdicts', () => {
});
});

test('buildSnapshotState preserves Android effective geometry for post-wire consumers', () => {
const xml = `<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][400,800]" visible-to-user="true">
<node class="android.widget.ScrollView" bounds="[0,100][300,500]" scrollable="true" visible-to-user="true">
<node class="android.widget.Button" text="Partially visible" bounds="[200,300][320,380]" clickable="true" visible-to-user="true" />
</node>
</node>
</hierarchy>`;
const built = buildUiHierarchySnapshot(parseUiHierarchyTree(xml), undefined, {});
const state = buildSnapshotState({ nodes: built.nodes, backend: 'android' }, undefined);
const target = state.nodes.find((node) => node.label === 'Partially visible');

expect(target).toMatchObject({
rect: { x: 200, y: 300, width: 100, height: 80 },
hittable: true,
});
expect(target && isNodeVisibleOnScreen(target, state.nodes)).toBe(true);
});

test('buildSnapshotState handles nodes with missing fields', () => {
const state = buildSnapshotState(
{
Expand Down
107 changes: 107 additions & 0 deletions src/platforms/android/__tests__/snapshot-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Rect } from '@agent-device/kernel/snapshot';
import fc from 'fast-check';
import { expect, test } from 'vitest';
import { PROPERTY_RUNS } from '../../../__tests__/test-utils/property-arbitraries.ts';
import {
createAndroidSnapshotPresentationNode,
effectiveAndroidRect,
serializeAndroidRegularPresentationNode,
} from '../snapshot-presentation.ts';
import { parseUiHierarchy } from './ui-hierarchy-fixtures.ts';

const rectArb = fc.record({
x: fc.integer({ min: -500, max: 500 }),
y: fc.integer({ min: -500, max: 500 }),
width: fc.integer({ min: -20, max: 500 }),
height: fc.integer({ min: -20, max: 500 }),
});

const positiveRectArb = fc.record({
x: fc.integer({ min: -500, max: 500 }),
y: fc.integer({ min: -500, max: 500 }),
width: fc.integer({ min: 1, max: 500 }),
height: fc.integer({ min: 1, max: 500 }),
});

test('regular Android snapshots publish cumulative effective geometry while raw keeps reported bounds', () => {
const xml = `<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][400,800]" window-bounds="[0,0][400,800]" visible-to-user="true">
<node class="android.widget.ScrollView" bounds="[0,100][300,500]" scrollable="true" visible-to-user="true">
<node class="android.widget.ScrollView" bounds="[50,150][250,350]" scrollable="true" visible-to-user="true">
<node class="android.widget.Button" text="Partially visible" bounds="[200,300][320,380]" clickable="true" visible-to-user="true" />
<node class="android.widget.Button" text="Outside nested scroll" bounds="[260,360][380,420]" clickable="true" visible-to-user="true" />
</node>
</node>
</node>
</hierarchy>`;

const regular = parseUiHierarchy(xml, 800, {});
const raw = parseUiHierarchy(xml, 800, { raw: true });

expect(regular.nodes.find((node) => node.label === 'Partially visible')).toMatchObject({
rect: { x: 200, y: 300, width: 50, height: 50 },
hittable: true,
});
expect(regular.nodes.find((node) => node.label === 'Outside nested scroll')).toMatchObject({
rect: { x: 260, y: 360, width: 0, height: 0 },
hittable: undefined,
});
expect(raw.nodes.find((node) => node.label === 'Partially visible')).toMatchObject({
rect: { x: 200, y: 300, width: 120, height: 80 },
hittable: true,
});
expect(raw.nodes.find((node) => node.label === 'Outside nested scroll')).toMatchObject({
rect: { x: 260, y: 360, width: 120, height: 60 },
hittable: true,
});
});

test('regular Android snapshots clip descendants to their owning window', () => {
const xml = `<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][400,800]" window-bounds="[0,0][400,800]" window-index="0" window-type="1" window-layer="1" window-active="true" window-focused="true" visible-to-user="true">
<node class="android.widget.Button" text="Main action" bounds="[20,20][180,80]" clickable="true" visible-to-user="true" />
</node>
<node class="android.widget.FrameLayout" bounds="[100,100][300,300]" window-bounds="[100,100][300,300]" window-index="1" window-type="2" window-layer="2" window-active="true" window-focused="true" visible-to-user="true">
<node class="android.widget.Button" text="Outside dialog" bounds="[120,320][180,380]" clickable="true" visible-to-user="true" />
</node>
</hierarchy>`;

const regular = parseUiHierarchy(xml, 800, {});
const dialogAction = regular.nodes.find((node) => node.label === 'Outside dialog');

expect(dialogAction).toMatchObject({
rect: { x: 120, y: 320, width: 0, height: 0 },
hittable: undefined,
});
});

test('property: effective Android geometry stays inside every positive clip and never upgrades actionability', () => {
fc.assert(
fc.property(rectArb, positiveRectArb, positiveRectArb, (reported, viewport, ancestorClip) => {
const effective = effectiveAndroidRect(reported, viewport, ancestorClip);
const raw = { index: 0, depth: 0, rect: reported, hittable: true as const };
const carrier = createAndroidSnapshotPresentationNode(raw, effective);
const regular = serializeAndroidRegularPresentationNode(carrier);

expect(carrier.raw.rect).toEqual(reported);
expect(regular.rect).toEqual(effective);
if (effective && effective.width > 0 && effective.height > 0) {
expect(rectContains(viewport, effective)).toBe(true);
expect(rectContains(ancestorClip, effective)).toBe(true);
expect(regular.hittable).toBe(true);
} else {
expect(regular.hittable).toBeUndefined();
}
}),
{ numRuns: PROPERTY_RUNS },
);
});

function rectContains(outer: Rect, inner: Rect): boolean {
return (
inner.x >= outer.x &&
inner.y >= outer.y &&
inner.x + inner.width <= outer.x + Math.max(0, outer.width) &&
inner.y + inner.height <= outer.y + Math.max(0, outer.height)
);
}
28 changes: 28 additions & 0 deletions src/platforms/android/__tests__/ui-hierarchy-builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from 'vitest';
import { parseUiHierarchyTree } from '../ui-hierarchy.ts';
import { buildUiHierarchySnapshot } from '../ui-hierarchy-builder.ts';

test('builder clips each Android node to its owning window before regular presentation', () => {
// Planted red on the pre-#1968 builder: the dialog descendant inherited the largest application
// viewport instead of the dialog window and kept a positive rectangle outside that window.
const tree = parseUiHierarchyTree(`<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][400,800]"
window-bounds="[0,0][400,800]" window-index="0" window-type="1"
window-layer="1" window-active="true" window-focused="true" visible-to-user="true">
<node class="android.widget.Button" text="Main action" bounds="[20,20][180,80]"
clickable="true" visible-to-user="true" />
</node>
<node class="android.widget.FrameLayout" bounds="[100,100][300,300]"
window-bounds="[100,100][300,300]" window-index="1" window-type="2"
window-layer="2" window-active="true" window-focused="true" visible-to-user="true">
<node class="android.widget.Button" text="Outside dialog" bounds="[120,320][180,380]"
clickable="true" visible-to-user="true" />
</node>
</hierarchy>`);

const { nodes } = buildUiHierarchySnapshot(tree, undefined, {});
expect(nodes.find((node) => node.label === 'Outside dialog')).toMatchObject({
rect: { x: 120, y: 320, width: 0, height: 0 },
hittable: undefined,
});
});
68 changes: 68 additions & 0 deletions src/platforms/android/__tests__/ui-hierarchy-inclusion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, test } from 'vitest';
import { shouldIncludeAndroidNode } from '../ui-hierarchy-inclusion.ts';
import type { AndroidNode } from '../ui-hierarchy-node.ts';

test('interactive inclusion keeps positive agent targets and rejects degenerate geometry', () => {
expect(
shouldIncludeAndroidNode(
node({ clickable: true }),
{ interactiveOnly: true },
false,
false,
false,
),
).toBe(true);
expect(
shouldIncludeAndroidNode(
node({ clickable: true, rect: { x: 10, y: 10, width: 0, height: 20 } }),
{ interactiveOnly: true },
false,
false,
false,
),
).toBe(false);
});

test('interactive inclusion keeps a semantic Compose proxy only in actionable context', () => {
const semanticView = node({ type: 'android.view.View', label: 'Start a call' });
expect(
shouldIncludeAndroidNode(semanticView, { interactiveOnly: true }, true, false, false),
).toBe(true);
expect(
shouldIncludeAndroidNode(semanticView, { interactiveOnly: true }, false, false, false),
).toBe(false);
});

test('regular inclusion preserves meaningful structural nodes and actionable descendants', () => {
expect(
shouldIncludeAndroidNode(
node({ type: 'android.view.View', label: 'Panel' }),
{},
false,
false,
false,
),
).toBe(true);
expect(
shouldIncludeAndroidNode(node({ type: 'android.view.View' }), {}, false, true, false),
).toBe(true);
expect(
shouldIncludeAndroidNode(node({ type: 'android.view.View' }), {}, false, false, false),
).toBe(false);
});

function node(overrides: Partial<AndroidNode>): AndroidNode {
return {
type: 'android.widget.Button',
label: null,
value: null,
identifier: null,
packageName: null,
rect: { x: 0, y: 0, width: 20, height: 20 },
clickable: false,
focusable: false,
depth: 0,
children: [],
...overrides,
};
}
76 changes: 76 additions & 0 deletions src/platforms/android/snapshot-presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';

/**
* The typed carrier between Android acquisition facts and regular snapshot wire projection.
*
* `raw.rect` remains the helper-reported frame for raw output and traversal. `effectiveRect` is
* the frame after the Android presentation clip fold. Keeping both values in one object prevents
* a downstream consumer from accidentally publishing acquisition geometry for a regular node.
*/
export type AndroidSnapshotPresentationNode = {
raw: RawSnapshotNode;
effectiveRect?: Rect;
};

export function createAndroidSnapshotPresentationNode(
raw: RawSnapshotNode,
effectiveRect?: Rect,
): AndroidSnapshotPresentationNode {
return { raw, ...(effectiveRect ? { effectiveRect } : {}) };
}

/**
* Serializes the regular projection. The reported rectangle never crosses this boundary, and an
* actionability claim is retained only when the effective geometry has positive area.
*/
export function serializeAndroidRegularPresentationNode(
node: AndroidSnapshotPresentationNode,
): RawSnapshotNode {
return {
...node.raw,
...(node.effectiveRect ? { rect: node.effectiveRect } : { rect: undefined }),
hittable:
node.raw.hittable === true && isPositiveFiniteRect(node.effectiveRect) ? true : undefined,
};
}

/**
* Folds the Android viewport and the nearest effective scroll clip into one cumulative clip.
* Empty intersections keep the reported origin and become zero-area frames, matching the Swift
* presentation geometry contract and keeping diagnostic coordinates useful.
*/
export function effectiveAndroidRect(
reportedRect: Rect | undefined,
viewport: Rect | undefined,
ancestorClip: Rect | undefined,
): Rect | undefined {
if (!reportedRect) return undefined;
let effective = normalizeRect(reportedRect);
if (viewport) effective = intersectRect(effective, viewport);
if (ancestorClip) effective = intersectRect(effective, ancestorClip);
return effective;
}

function intersectRect(left: Rect, right: Rect): Rect {
const x = Math.max(left.x, right.x);
const y = Math.max(left.y, right.y);
const maxX = Math.min(left.x + left.width, right.x + right.width);
const maxY = Math.min(left.y + left.height, right.y + right.height);
const hasIntersection = maxX > x && maxY > y;
return {
x: hasIntersection ? x : left.x,
y: hasIntersection ? y : left.y,
width: hasIntersection ? maxX - x : 0,
height: hasIntersection ? maxY - y : 0,
};
}

function normalizeRect(rect: Rect): Rect {
return {
x: rect.x,
y: rect.y,
width: Math.max(0, rect.width),
height: Math.max(0, rect.height),
};
}
Loading
Loading