Skip to content

Commit d6cd3d1

Browse files
committed
fix(android): budget scoped snapshot presentation
1 parent fa11ff6 commit d6cd3d1

4 files changed

Lines changed: 109 additions & 34 deletions

File tree

‎src/platforms/android/__tests__/ui-hierarchy-fixtures.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import type { RawSnapshotNode, SnapshotOptions } from '@agent-device/kernel/snapshot';
1+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
22
import {
33
buildUiHierarchySnapshot,
44
parseUiHierarchyTree,
55
type AndroidSnapshotAnalysis,
6+
type AndroidUiHierarchySnapshotOptions,
67
} from '../ui-hierarchy.ts';
78

89
/** XML in, presented nodes out — the production pair (`snapshotAndroid`) minus the helper capture. */
910
export function parseUiHierarchy(
1011
xml: string,
1112
maxNodes: number | undefined,
12-
options: SnapshotOptions,
13+
options: AndroidUiHierarchySnapshotOptions,
1314
): { nodes: RawSnapshotNode[]; truncated?: boolean; analysis: AndroidSnapshotAnalysis } {
1415
const { sourceNodes: _sourceNodes, ...snapshot } = buildUiHierarchySnapshot(
1516
parseUiHierarchyTree(xml),

‎src/platforms/android/__tests__/ui-hierarchy-scope.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import fs from 'node:fs';
44
import path from 'node:path';
55
import { buildSnapshotState } from '../../../daemon/snapshot-state.ts';
66
import { parseUiHierarchy } from './ui-hierarchy-fixtures.ts';
7+
import {
8+
AndroidSnapshotPresentationFailure,
9+
isAndroidSnapshotPresentationFailure,
10+
} from '../snapshot-presentation.ts';
711

812
// Android's scope leg of the golden table (#1832 C2). Android resolves `--scope` exactly once,
913
// over the PRESENTED nodes of the requested projection, inside its projection; the daemon never
@@ -235,3 +239,36 @@ test('a match whose subtree presents nothing is skipped for the next candidate',
235239
['Settings'],
236240
);
237241
});
242+
243+
test('hostile nested scope search is charged to the presentation budget', () => {
244+
const depth = 180;
245+
const opening = Array.from(
246+
{ length: depth },
247+
() =>
248+
'<node class="android.view.View" content-desc="target" bounds="[0,0][320,640]" visible-to-user="true">',
249+
).join('');
250+
const xml = `<hierarchy>${opening}${'</node>'.repeat(depth)}</hierarchy>`;
251+
const androidPresentation = {
252+
deadlineAtMs: Number.POSITIVE_INFINITY,
253+
maxWorkUnits: depth * 10,
254+
};
255+
256+
assert.doesNotThrow(() =>
257+
parseUiHierarchy(xml, undefined, { interactiveOnly: true, androidPresentation }),
258+
);
259+
260+
assert.throws(
261+
() =>
262+
parseUiHierarchy(xml, undefined, {
263+
interactiveOnly: true,
264+
scope: 'target',
265+
androidPresentation,
266+
}),
267+
(error: unknown) => {
268+
assert.equal(isAndroidSnapshotPresentationFailure(error), true);
269+
assert(error instanceof AndroidSnapshotPresentationFailure);
270+
assert.equal(error.details.phase, 'complexity');
271+
return true;
272+
},
273+
);
274+
});

‎src/platforms/android/ui-hierarchy-builder.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
type AndroidSystemChromeProvenance,
33
isAndroidSystemChromeWindowResourceId,
4-
} from '@agent-device/contracts/platform';
4+
} from '@agent-device/contracts/android-system-chrome';
55
import { isScrollableType, normalizeSnapshotScope } from '@agent-device/contracts/snapshot';
66
import type { RawSnapshotNode, Rect, SnapshotOptions } from '@agent-device/kernel/snapshot';
77
import { isPositiveFiniteRect, pickLargestRect } from '@agent-device/kernel/rect';
@@ -100,19 +100,24 @@ export function buildUiHierarchySnapshot(
100100
presentationBudget,
101101
);
102102
}
103+
const { nodes, sourceNodes } = scope
104+
? scopePresentedAndroidSnapshot(
105+
state,
106+
tree.children,
107+
scope,
108+
requestedDepth,
109+
presentationBudget,
110+
)
111+
: state;
112+
const snapshot = { nodes, sourceNodes, analysis: state.analysis };
113+
return state.truncated ? { ...snapshot, truncated: true } : snapshot;
103114
} catch (error) {
104115
if (isAndroidSnapshotPresentationFailure(error)) {
105116
error.analysis ??= state.analysis;
106117
throw error;
107118
}
108119
throw error;
109120
}
110-
111-
const { nodes, sourceNodes } = scope
112-
? scopePresentedAndroidSnapshot(state, tree.children, scope, requestedDepth)
113-
: state;
114-
const snapshot = { nodes, sourceNodes, analysis: state.analysis };
115-
return state.truncated ? { ...snapshot, truncated: true } : snapshot;
116121
}
117122

118123
/**

‎src/platforms/android/ui-hierarchy-scope.ts‎

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
reindexSnapshotNodes,
44
type SnapshotScopeCandidate,
55
} from '@agent-device/contracts/snapshot';
6+
import type { AndroidSnapshotPresentationBudget } from './snapshot-presentation.ts';
67

78
type PresentedNode = { index: number; depth?: number; parentIndex?: number };
89
type SourceNode = SnapshotScopeCandidate & { depth: number; children: SourceNode[] };
@@ -46,21 +47,33 @@ export function scopePresentedAndroidSnapshot<
4647
roots: readonly Source[],
4748
scope: string,
4849
maxDepth: number,
50+
budget: AndroidSnapshotPresentationBudget,
4951
): AndroidPresentedNodes<Node, Source> {
5052
const presented = new Set<Source>(state.sourceNodes);
51-
const scopeRoot = findAndroidScopeRoot(roots, scope, presented);
53+
const order = collectDocumentOrder(roots, budget);
54+
const contributing = collectPresentedSubtrees(order, presented, budget);
55+
const scopeRoot = findAndroidScopeRoot(order, scope, contributing, budget);
5256
if (!scopeRoot) return { nodes: [], sourceNodes: [] };
5357

54-
const inScope = collectSubtree(scopeRoot);
55-
const subtree = [...state.sourceNodes.entries()]
56-
.filter(([, source]) => inScope.has(source))
57-
.map(([position]) => position);
58+
const inScope = new Set(collectDocumentOrder([scopeRoot], budget));
59+
const subtree: number[] = [];
60+
for (const [position, source] of state.sourceNodes.entries()) {
61+
budget.check('work');
62+
if (inScope.has(source)) subtree.push(position);
63+
}
5864
if (subtree.length === 0) return { nodes: [], sourceNodes: [] };
5965

60-
const depthOffset = Math.min(...subtree.map((position) => state.nodes[position]?.depth ?? 0));
61-
const positions = subtree.filter(
62-
(position) => (state.nodes[position]?.depth ?? 0) - depthOffset <= maxDepth,
63-
);
66+
let depthOffset = Number.POSITIVE_INFINITY;
67+
for (const position of subtree) {
68+
budget.check('work');
69+
depthOffset = Math.min(depthOffset, state.nodes[position]?.depth ?? 0);
70+
}
71+
const positions: number[] = [];
72+
for (const position of subtree) {
73+
budget.check('work');
74+
if ((state.nodes[position]?.depth ?? 0) - depthOffset <= maxDepth) positions.push(position);
75+
}
76+
budget.consume(positions.length);
6477
return {
6578
nodes: reindexSnapshotNodes(
6679
positions.map((position) => state.nodes[position] as Node),
@@ -72,37 +85,56 @@ export function scopePresentedAndroidSnapshot<
7285

7386
/** First document-order match whose subtree contributes presented content. */
7487
function findAndroidScopeRoot<Source extends SourceNode>(
75-
roots: readonly Source[],
88+
order: readonly Source[],
7689
scope: string,
77-
presented: ReadonlySet<Source>,
90+
contributing: ReadonlySet<Source>,
91+
budget: AndroidSnapshotPresentationBudget,
7892
): Source | null {
79-
for (const node of documentOrder(roots)) {
80-
if (matchesSnapshotScope(node, scope) && subtreeHasPresentedNode(node, presented)) return node;
93+
for (const node of order) {
94+
budget.check('work');
95+
if (matchesSnapshotScope(node, scope) && contributing.has(node)) return node;
8196
}
8297
return null;
8398
}
8499

85-
function* documentOrder<Source extends SourceNode>(roots: readonly Source[]): Generator<Source> {
100+
function collectDocumentOrder<Source extends SourceNode>(
101+
roots: readonly Source[],
102+
budget: AndroidSnapshotPresentationBudget,
103+
): Source[] {
104+
const order: Source[] = [];
86105
const stack = [...roots].reverse();
87106
while (stack.length > 0) {
107+
budget.check('work');
88108
const node = stack.pop() as Source;
89-
yield node;
109+
order.push(node);
90110
for (let index = node.children.length - 1; index >= 0; index -= 1) {
111+
budget.check('work');
91112
stack.push(node.children[index] as Source);
92113
}
93114
}
115+
return order;
94116
}
95117

96-
function subtreeHasPresentedNode<Source extends SourceNode>(
97-
root: Source,
118+
function collectPresentedSubtrees<Source extends SourceNode>(
119+
order: readonly Source[],
98120
presented: ReadonlySet<Source>,
99-
): boolean {
100-
for (const node of documentOrder([root])) {
101-
if (presented.has(node)) return true;
121+
budget: AndroidSnapshotPresentationBudget,
122+
): ReadonlySet<Source> {
123+
const contributing = new Set<Source>();
124+
for (let index = order.length - 1; index >= 0; index -= 1) {
125+
budget.check('work');
126+
const node = order[index] as Source;
127+
if (presented.has(node)) {
128+
contributing.add(node);
129+
continue;
130+
}
131+
for (const child of node.children) {
132+
budget.check('work');
133+
if (contributing.has(child as Source)) {
134+
contributing.add(node);
135+
break;
136+
}
137+
}
102138
}
103-
return false;
104-
}
105-
106-
function collectSubtree<Source extends SourceNode>(root: Source): ReadonlySet<Source> {
107-
return new Set(documentOrder([root]));
139+
return contributing;
108140
}

0 commit comments

Comments
 (0)