Skip to content

Commit 796525c

Browse files
cameroncookecodex
andcommitted
fix(ui-automation): Tighten post-action snapshot handling
Skip post-action snapshot capture for safe same-screen batches, add long-press capture parity, and cover runtime next-step foreground ranking rules directly. Co-Authored-By: Codex <noreply@openai.com>
1 parent ca9f245 commit 796525c

6 files changed

Lines changed: 197 additions & 11 deletions

File tree

src/mcp/tools/ui-automation/__tests__/batch.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,16 +203,22 @@ describe('Batch UI Automation Tool', () => {
203203
createNode({ type: 'Switch', role: 'AXSwitch', AXValue: '0' }),
204204
createNode({ type: 'Switch', role: 'AXSwitch', AXValue: 'off' }),
205205
]);
206+
const { calls, executor } = createTrackingExecutor();
206207

207-
const result = await runBatch({
208-
simulatorId,
209-
steps: [
210-
{ action: 'tap', elementRef: 'e1' },
211-
{ action: 'tap', elementRef: 'e2' },
212-
],
213-
});
208+
const result = await runBatch(
209+
{
210+
simulatorId,
211+
steps: [
212+
{ action: 'tap', elementRef: 'e1' },
213+
{ action: 'tap', elementRef: 'e2' },
214+
],
215+
},
216+
executor,
217+
);
214218

215219
expect(result.didError).toBe(false);
220+
expect(result.capture).toBeUndefined();
221+
expect(calls.some((call) => call.command[1] === 'describe-ui')).toBe(false);
216222
expect(getRuntimeSnapshot(simulatorId)).not.toBeNull();
217223
});
218224

src/mcp/tools/ui-automation/__tests__/long_press.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ describe('Long Press Plugin', () => {
5959
expect(result).toMatchObject({
6060
didError: false,
6161
action: { type: 'long-press', elementRef: 'e1', durationMs: 1500 },
62+
capture: { type: 'runtime-snapshot', simulatorId },
6263
});
63-
expect(calls).toHaveLength(1);
6464
expect(calls[0]?.command).toEqual([
6565
'/mocked/axe/path',
6666
'touch',
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import type { AccessibilityNode } from '../../../../types/domain-results.ts';
3+
import { createRuntimeSnapshotNextSteps } from '../shared/runtime-next-steps.ts';
4+
import {
5+
__resetRuntimeSnapshotStoreForTests,
6+
getRuntimeSnapshot,
7+
} from '../shared/snapshot-ui-state.ts';
8+
import { createNode, recordSnapshot, simulatorId } from './ui-action-test-helpers.ts';
9+
10+
function currentRuntimeSnapshot() {
11+
const snapshot = getRuntimeSnapshot(simulatorId);
12+
expect(snapshot).not.toBeNull();
13+
return snapshot!.payload;
14+
}
15+
16+
function createScrollView(overrides: Partial<AccessibilityNode> = {}): AccessibilityNode {
17+
return createNode({
18+
type: 'ScrollView',
19+
role: 'AXScrollArea',
20+
frame: { x: 0, y: 0, width: 390, height: 844 },
21+
AXIdentifier: 'scroll-view',
22+
...overrides,
23+
});
24+
}
25+
26+
function nestNode(node: AccessibilityNode, depth: number): AccessibilityNode {
27+
let current = node;
28+
for (let index = 0; index < depth; index += 1) {
29+
current = createNode({
30+
type: 'Group',
31+
role: 'AXGroup',
32+
AXIdentifier: `container.${index}`,
33+
frame: current.frame,
34+
children: [current],
35+
});
36+
}
37+
return current;
38+
}
39+
40+
describe('runtime snapshot next steps', () => {
41+
beforeEach(() => {
42+
__resetRuntimeSnapshotStoreForTests();
43+
});
44+
45+
it('prefers tap and scroll examples from the active foreground container', () => {
46+
recordSnapshot([
47+
createScrollView({
48+
AXIdentifier: 'weather.backgroundList',
49+
children: [
50+
createNode({
51+
AXLabel: 'Background, Details',
52+
AXIdentifier: 'weather.backgroundCard',
53+
frame: { x: 20, y: 120, width: 350, height: 80 },
54+
}),
55+
],
56+
}),
57+
createScrollView({
58+
AXIdentifier: 'weather.settingsSheet',
59+
frame: { x: 0, y: 420, width: 390, height: 424 },
60+
children: [
61+
createNode({ AXLabel: 'Close', frame: { x: 310, y: 430, width: 60, height: 40 } }),
62+
createNode({
63+
type: 'TextField',
64+
role: 'AXTextField',
65+
AXLabel: 'Search',
66+
frame: { x: 20, y: 480, width: 350, height: 40 },
67+
}),
68+
createNode({
69+
AXLabel: 'London, England',
70+
AXIdentifier: 'weather.locationCard',
71+
frame: { x: 20, y: 540, width: 350, height: 80 },
72+
}),
73+
],
74+
}),
75+
]);
76+
77+
const snapshot = currentRuntimeSnapshot();
78+
const foregroundScrollRef = snapshot.elements.find(
79+
(element) => element.identifier === 'weather.settingsSheet',
80+
)?.ref;
81+
const foregroundCardRef = snapshot.elements.find(
82+
(element) => element.identifier === 'weather.locationCard',
83+
)?.ref;
84+
85+
const steps = createRuntimeSnapshotNextSteps({
86+
simulatorId,
87+
runtimeSnapshot: snapshot,
88+
includeRefreshAndWait: false,
89+
});
90+
91+
expect(steps).toContainEqual({
92+
label: 'Tap an elementRef',
93+
tool: 'tap',
94+
params: { simulatorId, elementRef: foregroundCardRef },
95+
});
96+
expect(steps).toContainEqual({
97+
label: 'Scroll visible content',
98+
tool: 'swipe',
99+
params: {
100+
simulatorId,
101+
withinElementRef: foregroundScrollRef,
102+
direction: 'up',
103+
distance: 0.5,
104+
},
105+
});
106+
});
107+
108+
it('uses hierarchy depth only as a foreground-root tie breaker', () => {
109+
recordSnapshot([
110+
nestNode(
111+
createScrollView({
112+
AXIdentifier: 'deep.stateControls',
113+
frame: { x: 0, y: 0, width: 390, height: 80 },
114+
children: [
115+
createNode({
116+
type: 'Switch',
117+
role: 'AXSwitch',
118+
AXLabel: 'Nested switch',
119+
AXValue: '0',
120+
}),
121+
],
122+
}),
123+
40,
124+
),
125+
createScrollView({
126+
AXIdentifier: 'shallow.searchPanel',
127+
frame: { x: 0, y: 100, width: 390, height: 500 },
128+
children: [
129+
createNode({
130+
type: 'TextField',
131+
role: 'AXTextField',
132+
AXLabel: 'Search',
133+
frame: { x: 20, y: 130, width: 350, height: 40 },
134+
}),
135+
],
136+
}),
137+
]);
138+
139+
const snapshot = currentRuntimeSnapshot();
140+
const shallowSearchRef = snapshot.elements.find(
141+
(element) => element.identifier === 'shallow.searchPanel',
142+
)?.ref;
143+
144+
const steps = createRuntimeSnapshotNextSteps({
145+
simulatorId,
146+
runtimeSnapshot: snapshot,
147+
includeRefreshAndWait: false,
148+
});
149+
150+
expect(steps).toContainEqual({
151+
label: 'Scroll visible content',
152+
tool: 'swipe',
153+
params: {
154+
simulatorId,
155+
withinElementRef: shallowSearchRef,
156+
direction: 'up',
157+
distance: 0.5,
158+
},
159+
});
160+
});
161+
});

src/mcp/tools/ui-automation/batch.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ export function createBatchExecutor(
216216
});
217217
}
218218

219+
if (resolvedSteps.preserveSnapshot) {
220+
return createUiActionSuccessResult(action, simulatorId, [guard.warningText]);
221+
}
222+
219223
const captureResult = await captureRuntimeSnapshotAfterActionSafely({
220224
simulatorId,
221225
executor,

src/mcp/tools/ui-automation/long_press.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { clearRuntimeSnapshot, resolveElementRef } from './shared/snapshot-ui-state.ts';
2121
import { getRuntimeElementActivationPoint } from './shared/runtime-snapshot.ts';
2222
import { executeAxeCommand, defaultAxeHelpers } from './shared/axe-command.ts';
23+
import { captureRuntimeSnapshotAfterActionSafely } from './shared/post-action-snapshot.ts';
2324
import type { AxeHelpers } from './shared/axe-command.ts';
2425
import type { NonStreamingExecutor } from '../../../types/tool-execution.ts';
2526
import type { UiActionResultDomainResult } from '../../../types/domain-results.ts';
@@ -100,7 +101,6 @@ export function createLongPressExecutor(
100101
await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers);
101102
clearRuntimeSnapshot(simulatorId);
102103
log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`);
103-
return createUiActionSuccessResult(action, simulatorId, [guard.warningText]);
104104
} catch (error) {
105105
if (shouldInvalidateRuntimeSnapshotAfterActionError(error)) {
106106
clearRuntimeSnapshot(simulatorId);
@@ -118,6 +118,21 @@ export function createLongPressExecutor(
118118
}),
119119
});
120120
}
121+
122+
const captureResult = await captureRuntimeSnapshotAfterActionSafely({
123+
simulatorId,
124+
executor,
125+
axeHelpers,
126+
});
127+
return createUiActionSuccessResult(
128+
action,
129+
simulatorId,
130+
[guard.warningText, captureResult.warning],
131+
{
132+
...(captureResult.capture ? { capture: captureResult.capture } : {}),
133+
...(captureResult.uiError ? { uiError: captureResult.uiError } : {}),
134+
},
135+
);
121136
};
122137
}
123138

src/mcp/tools/ui-automation/shared/runtime-next-steps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,8 @@ function findActiveForegroundRoot(
270270
(hasDismissControl ? 100 : 0) +
271271
(hasTextEntry ? 60 : 0) +
272272
(hasStateControls ? 30 : 0) +
273-
record.metadata.depth +
274-
(indexByRef.get(record.publicElement.ref) ?? 0) / 1000;
273+
record.metadata.depth / 1000 +
274+
(indexByRef.get(record.publicElement.ref) ?? 0) / 1_000_000;
275275
scoreByRef.set(record.publicElement.ref, score);
276276
return score;
277277
}

0 commit comments

Comments
 (0)