Skip to content

Commit 93c183f

Browse files
committed
fix(interaction): refuse taps whose target sits behind the visible keyboard
An acting target whose rect center lands inside the visible keyboard's band is refused with `tap_keyboard_occludes_target`, because the touch activates a key instead of the named element (#2589). Neither sibling guarantee reached it: the keyboard is a separate system surface, so it is never a covering sibling for `occlusion`, and a covered tab bar stays inside the app's own window rect for `offscreen`. The guard runs at the shared pipeline door, so selector, `@ref`, the native-ref preflight, and both drag endpoints read one decision, and a backend that "succeeds" cannot do so on a target the shared rules refuse. Raw coordinates keep their escape hatch and disclose the same reason in the response warning instead: they name a point rather than an element, they are how a keyboard's own control is tapped, and that path never captures the tree the band is measured against. The keyboard's own keys and dock items stay tappable, an element whose center stays above the key plane keeps pressing, and an unmeasurable keyboard fails open. Versioned help and the iOS runner hint no longer claim the keyboard usually does not block interactions. Closes #2589
1 parent 839fcc7 commit 93c183f

13 files changed

Lines changed: 572 additions & 43 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2187,7 +2187,7 @@ extension RunnerTests {
21872187
code: "UNSUPPORTED_OPERATION",
21882188
message: "Unable to dismiss the iOS keyboard: the keyboard exposes no dismiss key, and background taps are never attempted (no tap outside the keyboard can be proven side-effect-free)",
21892189
hint:
2190-
"The on-screen keyboard usually does not block agent-device interactions: press the next target directly instead of retrying dismiss. If that press fails or reports no visible effect, scroll the target into view, or use keyboard enter to press the return key when submission is wanted."
2190+
"An element whose center sits behind the on-screen keyboard is refused with tap_keyboard_occludes_target; one whose center stays above the keys presses normally. To end editing, tap the app's own Done/Cancel control, or use keyboard enter to press the return key when submission is wanted."
21912191
)
21922192
)
21932193
}

‎packages/contracts/src/interaction-guarantees.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export const INTERACTION_GUARANTEES = [
3434
'disambiguation',
3535
// Targets covered by another visible element are refused.
3636
'occlusion',
37+
// Targets whose tap point sits behind the visible software keyboard are refused. The keyboard is
38+
// its own system surface, so `occlusion` cannot see it and `offscreen` passes it (#2589).
39+
'keyboardOcclusion',
3740
// Element-targeted coordinate paths keep the resolved parent identity while
3841
// choosing a point outside independently interactive descendants. A parent
3942
// whose safe region is fully tiled fails closed instead of activating a child.
@@ -161,6 +164,15 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
161164
kind: 'runtime',
162165
via: 'packages/selectors/src/interaction-touch-point.ts#resolveInteractionTouchPoint',
163166
},
167+
// #2589: one guard for every acting node path, run at the shared pipeline door
168+
// (`runInteractionPipelineStages`), so the native-ref fast path cannot succeed on a target the
169+
// shared rule refuses. Its band is derived from the tree the path already holds: the keyboard is
170+
// never a covering sibling of app content and never leaves the app window rect, so neither
171+
// `occlusion` nor `offscreen` can reach it.
172+
keyboardOcclusion: {
173+
kind: 'runtime',
174+
via: 'src/commands/interaction/runtime/keyboard-occlusion.ts#assertTapTargetClearOfVisibleKeyboard',
175+
},
164176
// #1542: the base decision is the contracts-owned snapshot visibility resolver (bulk accessibility
165177
// tree), but throwIfOffscreenInteractionTarget is the actual end-to-end
166178
// enforcement point — on iOS (local, non-provider sessions only) a would-be
@@ -269,14 +281,18 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
269281
kind: 'runtime',
270282
via: 'packages/selectors/src/interaction-touch-point.ts#resolveInteractionTouchPoint',
271283
},
284+
keyboardOcclusion: {
285+
kind: 'runtime',
286+
via: 'src/commands/interaction/runtime/keyboard-occlusion.ts#assertTapTargetClearOfVisibleKeyboard',
287+
},
272288
offscreen: {
273289
kind: 'runtime',
274290
via: 'src/commands/interaction/runtime/resolution.ts#throwIfOffscreenInteractionTarget',
275291
},
276292
nonHittable: {
277293
kind: 'inapplicable',
278294
reason:
279-
'Drag endpoints name contact coordinates and intentionally need not be independently tappable controls; covered and off-screen endpoints are still refused.',
295+
'Drag endpoints name contact coordinates and intentionally need not be independently tappable controls; covered, keyboard-occluded, and off-screen endpoints are still refused.',
280296
},
281297
responseConstruction: {
282298
kind: 'runtime',
@@ -323,6 +339,13 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
323339
kind: 'runtime',
324340
via: 'packages/selectors/src/selector-pipeline.ts#runNodePipelineStages',
325341
},
342+
// Same door as the runtime tree paths: the preflight IS `runInteractionPipelineStages`, so
343+
// the keyboard guard runs before the backend call even though `occlusion` above is served by
344+
// the snapshot annotation the pipeline reads.
345+
keyboardOcclusion: {
346+
kind: 'runtime',
347+
via: 'src/commands/interaction/runtime/keyboard-occlusion.ts#assertTapTargetClearOfVisibleKeyboard',
348+
},
326349
parentOwnedTouchPoint: {
327350
kind: 'inapplicable',
328351
reason:
@@ -388,6 +411,15 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
388411
kind: 'inapplicable',
389412
reason: 'Coordinates name the exact point to activate; no parent element is resolved.',
390413
},
414+
// The classifier runs, and the refusal reason reaches the response as a warning instead of an
415+
// error: coordinates are the escape hatch that still reaches a keyboard's own control, and
416+
// this path never captures the tree the band is measured against, so a refusal would trust an
417+
// arbitrarily stale snapshot. See `describeKeyboardOccludedPointWarning`.
418+
keyboardOcclusion: {
419+
kind: 'waived',
420+
reason:
421+
'Intentional: a raw point carries no element identity to excuse a deliberate keyboard tap, and the point is checked against the last-known tree rather than one captured for this action. The shared decision still runs and publishes `tap_keyboard_occludes_target` in the response warning.',
422+
},
391423
offscreen: {
392424
kind: 'runtime',
393425
via: 'src/commands/interaction/runtime/resolution.ts#resolveInteractionTarget',
@@ -435,6 +467,11 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
435467
reason:
436468
'Intentional: the direct element-tap outcome relies on XCTest hittability instead of the daemon snapshot occlusion classifier.',
437469
},
470+
keyboardOcclusion: {
471+
kind: 'waived',
472+
reason:
473+
'Intentional: the fused runner element tap never sees a daemon tree, so no band can be derived; the daemon-resolved sibling paths refuse the same target.',
474+
},
438475
parentOwnedTouchPoint: {
439476
kind: 'waived',
440477
reason:
@@ -491,6 +528,11 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
491528
kind: 'waived',
492529
reason: 'Intentional: Maestro taps resolved bounds regardless of overlay state.',
493530
},
531+
keyboardOcclusion: {
532+
kind: 'waived',
533+
reason:
534+
'Intentional: shares the occlusion waiver above — Maestro taps resolved bounds regardless of what overlays them.',
535+
},
494536
parentOwnedTouchPoint: {
495537
kind: 'waived',
496538
reason:

‎packages/platform-apple/src/runner/__tests__/runner-client.test.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -510,9 +510,9 @@ test('parseRunnerResponse preserves runner unsupported-operation codes', async (
510510
);
511511
});
512512

513-
test('parseRunnerResponse surfaces the keyboard-dismiss hint to press the next target directly', async () => {
513+
test('parseRunnerResponse surfaces the keyboard-dismiss hint naming the occlusion reason', async () => {
514514
const hint =
515-
'The on-screen keyboard usually does not block agent-device interactions: press the next target directly instead of retrying dismiss. If that press fails or reports no visible effect, scroll the target into view, or use keyboard enter to press the return key when submission is wanted.';
515+
"An element whose center sits behind the on-screen keyboard is refused with tap_keyboard_occludes_target; one whose center stays above the keys presses normally. To end editing, tap the app's own Done/Cancel control, or use keyboard enter to press the return key when submission is wanted.";
516516
const response = new Response(
517517
JSON.stringify({
518518
ok: false,
@@ -531,12 +531,8 @@ test('parseRunnerResponse surfaces the keyboard-dismiss hint to press the next t
531531
assert.ok(error instanceof AppError);
532532
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
533533
assert.equal(error.details?.hint, hint);
534-
assert.match(
535-
String(error.details?.hint),
536-
/usually does not block agent-device interactions/i,
537-
);
538-
assert.match(String(error.details?.hint), /press the next target directly/i);
539-
assert.match(String(error.details?.hint), /scroll the target into view/i);
534+
assert.match(String(error.details?.hint), /tap_keyboard_occludes_target/);
535+
assert.match(String(error.details?.hint), /center stays above the keys/i);
540536
assert.match(String(error.details?.hint), /keyboard enter/i);
541537
return true;
542538
},

‎src/cli-schema/cli-help.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Targets:
110110
Recovery:
111111
Network/typeahead result missing: wait text "Expected result" or wait <selector>.
112112
A target that should be gone/disappear: use wait absent <selector>; wait exists ... is rejected in favor of the plain selector wait.
113-
Keyboard visible over the next target: the on-screen keyboard usually does not block presses, so press the target directly instead of dismissing. If the press fails or reports no visible effect, scroll the target into view or use keyboard enter when submission is wanted.
113+
Keyboard visible over the next target: an element whose center sits behind the keyboard is refused with tap_keyboard_occludes_target, because the touch would activate a key instead of the target. End editing first -- the app's own Done/Cancel control, or keyboard enter when submission is wanted -- then retry. A target whose center stays above the keys still presses; a raw coordinate behind the keyboard taps anyway and reports the reason in warning.
114114
Sparse or recovered accessibility snapshot: use screenshot as visual truth, leave the bad screen if needed, then retry snapshot -i.
115115
Non-hittable success hint: verify with the settled diff or snapshot; retarget by a better ref/selector if the UI did not change.
116116
@@ -162,7 +162,7 @@ Text entry:
162162
fill replaces; type appends to an already-focused field: fill 'id="field-email"' "[email protected]"; type "Handle with care" --delay-ms 80
163163
fill <target> "" clears the field (replace with nothing); the empty argument must be present -- fill <target> alone is a missing argument.
164164
Plain fill/type first; if an iOS debounced/search-as-you-type field drops characters, retry with --delay-ms before clipboard paste.
165-
The keyboard usually does not block interactions -- press the next target directly. keyboard dismiss taps its own dismiss key when one exists, else UNSUPPORTED_OPERATION. Android: try dismiss before back. iOS: when both fail, do not tap a static text/heading hoping it is safe; prefer type "\\n" to submit.
165+
Element presses behind the keyboard are refused with tap_keyboard_occludes_target. keyboard dismiss taps its own dismiss key when one exists, else UNSUPPORTED_OPERATION. Android: try dismiss before back. iOS: when both fail, do not tap a static text/heading hoping it is safe; prefer type "\\n" to submit.
166166
iOS paste-prompt limits and Android IME/handwriting capture quirks: help debugging.
167167
168168
Session ordering:
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { InteractionAction } from './resolution.ts';
2+
3+
/**
4+
* The passive verb a refusal uses for the action it declined, so every acting guard that refuses
5+
* before dispatch describes the failure in the caller's own words.
6+
*/
7+
export function interactionVerb(action: InteractionAction): string {
8+
switch (action) {
9+
case 'fill':
10+
return 'be filled';
11+
case 'focus':
12+
return 'be focused';
13+
case 'longPress':
14+
return 'be long-pressed';
15+
case 'hover':
16+
return 'be hovered';
17+
default:
18+
return 'be tapped';
19+
}
20+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { Point, RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
4+
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';
5+
import { ref, selector } from './selector-read-utils.ts';
6+
import { createInteractionDevice } from './__tests__/test-utils/index.ts';
7+
8+
// #2589: the keyboard is its own system surface, so neither `occlusion` nor `offscreen` refuses a
9+
// tap whose point belongs to it. These cover the consequences the shared classifier cannot express:
10+
// which paths refuse, which disclose, and which stay silent.
11+
12+
const TAB_BAR_RECT = { x: 148, y: 791, width: 104, height: 83 };
13+
const KEYBOARD_RECT = { x: 0, y: 583, width: 402, height: 291 };
14+
const SPACE_KEY_RECT = { x: 40, y: 730, width: 240, height: 60 };
15+
16+
function keyboardNode(overrides: Partial<RawSnapshotNode> = {}): RawSnapshotNode {
17+
return {
18+
index: 2,
19+
depth: 1,
20+
parentIndex: 0,
21+
type: 'Keyboard',
22+
rect: KEYBOARD_RECT,
23+
hittable: false,
24+
...overrides,
25+
};
26+
}
27+
28+
function keyboardTree(params: { tabRect?: RawSnapshotNode['rect'] } = {}): SnapshotState {
29+
return makeSnapshotState([
30+
{ index: 0, depth: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
31+
{
32+
index: 1,
33+
depth: 2,
34+
parentIndex: 0,
35+
type: 'Button',
36+
label: 'Form',
37+
rect: params.tabRect ?? TAB_BAR_RECT,
38+
hittable: true,
39+
},
40+
keyboardNode(),
41+
{
42+
index: 3,
43+
depth: 2,
44+
parentIndex: 2,
45+
type: 'Key',
46+
label: 'space',
47+
rect: SPACE_KEY_RECT,
48+
hittable: true,
49+
},
50+
]);
51+
}
52+
53+
function tappedDevice(snapshot: SnapshotState, calls: Point[]) {
54+
return createInteractionDevice(snapshot, {
55+
tap: async (_context, point) => {
56+
calls.push(point);
57+
return { ok: true };
58+
},
59+
});
60+
}
61+
62+
test('press @ref refuses a target whose center sits behind the visible keyboard', async () => {
63+
const calls: Point[] = [];
64+
const device = tappedDevice(keyboardTree(), calls);
65+
66+
await assert.rejects(
67+
() => device.interactions.click(ref('@e2'), { session: 'default' }),
68+
(error: unknown) => {
69+
assert.ok(error instanceof Error);
70+
assert.equal((error as { code?: string }).code, 'COMMAND_FAILED');
71+
assert.match(error.message, /Ref @e2 is behind the visible keyboard/);
72+
const details = (error as { details?: Record<string, unknown> }).details;
73+
assert.equal(details?.reason, 'tap_keyboard_occludes_target');
74+
assert.equal(details?.ref, '@e2');
75+
assert.deepEqual(details?.rect, TAB_BAR_RECT);
76+
assert.deepEqual(details?.keyboardFrame, { x: 0, y: 583, width: 402, height: 291 });
77+
assert.match(String(details?.hint), /End editing first/);
78+
return true;
79+
},
80+
);
81+
assert.deepEqual(calls, []);
82+
});
83+
84+
test('fill refuses with the verb matching the action', async () => {
85+
const device = tappedDevice(keyboardTree(), []);
86+
await assert.rejects(
87+
() => device.interactions.fill(ref('@e2'), 'hello', { session: 'default' }),
88+
/cannot be filled safely/,
89+
);
90+
});
91+
92+
test('press selector refuses the same node the ref path refuses', async () => {
93+
const calls: Point[] = [];
94+
const device = tappedDevice(keyboardTree(), calls);
95+
96+
await assert.rejects(
97+
() => device.interactions.press(selector('label=Form'), { session: 'default' }),
98+
(error: unknown) => {
99+
assert.ok(error instanceof Error);
100+
assert.match(error.message, /Selector label=Form is behind the visible keyboard/);
101+
const details = (error as { details?: Record<string, unknown> }).details;
102+
assert.equal(details?.reason, 'tap_keyboard_occludes_target');
103+
return true;
104+
},
105+
);
106+
assert.deepEqual(calls, []);
107+
});
108+
109+
test('a keyboard key is a legitimate target: pressing one still taps', async () => {
110+
const calls: Point[] = [];
111+
const device = tappedDevice(keyboardTree(), calls);
112+
113+
await device.interactions.click(ref('@e4'), { session: 'default' });
114+
115+
assert.deepEqual(calls, [{ x: 160, y: 760 }]);
116+
});
117+
118+
test('an element whose center stays above the key plane still taps', async () => {
119+
const calls: Point[] = [];
120+
const peeking = { x: 148, y: 470, width: 104, height: 110 };
121+
const device = tappedDevice(keyboardTree({ tabRect: peeking }), calls);
122+
123+
await device.interactions.click(ref('@e2'), { session: 'default' });
124+
125+
assert.deepEqual(calls, [{ x: 200, y: 525 }]);
126+
});
127+
128+
test('no keyboard in the tree means nothing to refuse', async () => {
129+
const calls: Point[] = [];
130+
const withoutKeyboard = makeSnapshotState(
131+
keyboardTree().nodes.filter((node) => node.type !== 'Keyboard' && node.type !== 'Key'),
132+
);
133+
const device = tappedDevice(withoutKeyboard, calls);
134+
135+
await device.interactions.click(ref('@e2'), { session: 'default' });
136+
137+
assert.deepEqual(calls, [{ x: 200, y: 833 }]);
138+
});
139+
140+
test('an app-drawn keypad in the upper half is not the system keyboard', async () => {
141+
const calls: Point[] = [];
142+
const upperKeypad = makeSnapshotState([
143+
...keyboardTree().nodes.slice(0, 1),
144+
keyboardTree().nodes[1]!,
145+
keyboardNode({ rect: { x: 0, y: 60, width: 402, height: 220 } }),
146+
keyboardTree().nodes[3]!,
147+
]);
148+
const device = tappedDevice(upperKeypad, calls);
149+
150+
await device.interactions.click(ref('@e2'), { session: 'default' });
151+
152+
assert.deepEqual(calls, [{ x: 200, y: 833 }]);
153+
});
154+
155+
test('a coordinate behind the keyboard taps anyway and discloses the reason', async () => {
156+
const calls: Point[] = [];
157+
const device = tappedDevice(keyboardTree(), calls);
158+
159+
const result = await device.interactions.press(
160+
{ kind: 'point', x: 200, y: 810 },
161+
{
162+
session: 'default',
163+
},
164+
);
165+
166+
assert.deepEqual(calls, [{ x: 200, y: 810 }]);
167+
assert.match(result.warning ?? '', /behind the visible keyboard/);
168+
assert.match(result.warning ?? '', /tap_keyboard_occludes_target/);
169+
});
170+
171+
test('a coordinate on a reported key is the keyboard the caller asked for', async () => {
172+
const calls: Point[] = [];
173+
const device = tappedDevice(keyboardTree(), calls);
174+
175+
const result = await device.interactions.press(
176+
{ kind: 'point', x: 160, y: 760 },
177+
{
178+
session: 'default',
179+
},
180+
);
181+
182+
assert.deepEqual(calls, [{ x: 160, y: 760 }]);
183+
assert.equal(result.warning, undefined);
184+
});
185+
186+
test('the out-of-viewport disclosure still wins on a stale-tree coordinate', async () => {
187+
const device = tappedDevice(keyboardTree(), []);
188+
189+
const result = await device.interactions.press(
190+
{ kind: 'point', x: 900, y: 810 },
191+
{
192+
session: 'default',
193+
},
194+
);
195+
196+
assert.match(result.warning ?? '', /outside the last-known viewport/);
197+
});

0 commit comments

Comments
 (0)