Skip to content

Commit 280ebec

Browse files
committed
fix(interaction): guard the aim point the tap dispatches, not a second guess at it
The guard measured the target's rect center while the acting paths dispatch whatever `resolveNodeTouchPoint` resolves, and those two points are not the same node geometry: a parent whose interactive descendants own its center has its aim pushed into a child-free region. Center-first could wave through a tap whose real aim had moved into the key plane, and refuse one whose real aim had moved clear of it. The stage owner now resolves the aim once, measures that, and returns it for the dispatch, so one point is in play per acting tap. The native-ref fast path keeps the rect center and says why: it hands the element to the backend and dispatches by ref, so there is no coordinate to re-derive. The coordinate path's two disclosures shared their viewport lookup, which the keyboard warning had been resolving a second time for the same point. The runtime cases now build on the ADR 0011 fixture's #2589 tree instead of restating its rects, and one case pins the point rule from both sides: a target whose center stays above the key plane while its dispatched aim falls below it is refused, and the same tree with the keypad lifted off the bottom edge shows the tap aiming under the key plane.
1 parent 8ce997a commit 280ebec

3 files changed

Lines changed: 127 additions & 78 deletions

File tree

src/commands/interaction/runtime/keyboard-occlusion.test.ts

Lines changed: 76 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,33 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
3-
import type { Point, RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
3+
import type { Point, Rect, SnapshotState } from '@agent-device/kernel/snapshot';
44
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';
5+
import { keyboardCoveredTabBarSnapshot } from '../../../../test/integration/interaction-contract/fixtures.ts';
56
import { ref, selector } from './selector-read-utils.ts';
67
import { createInteractionDevice } from './__tests__/test-utils/index.ts';
78

89
// #2589: the keyboard is its own system surface, so neither `occlusion` nor `offscreen` refuses a
910
// 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-
]);
11+
// which paths refuse, which disclose, and which stay silent. The tree itself is the contract
12+
// fixtures' #2589 shape, so the refusal and the disclosure are measured on the same geometry the
13+
// ADR 0011 cells claim.
14+
15+
const TAB_BAR_RECT: Rect = { x: 148, y: 791, width: 104, height: 83 };
16+
17+
/** The contract fixture with its app-owned rects moved: the variants stay one tree, not copies. */
18+
function keyboardTree(params: { tabRect?: Rect; keyboardOwnedDy?: number } = {}): SnapshotState {
19+
if (!params.tabRect && params.keyboardOwnedDy === undefined)
20+
return keyboardCoveredTabBarSnapshot();
21+
return makeSnapshotState(
22+
keyboardCoveredTabBarSnapshot().nodes.map((node) => {
23+
if (params.tabRect && node.index === 1) return { ...node, rect: params.tabRect };
24+
const keyboardOwned = node.type === 'Keyboard' || node.type === 'Key';
25+
if (params.keyboardOwnedDy !== undefined && keyboardOwned && node.rect) {
26+
return { ...node, rect: { ...node.rect, y: node.rect.y + params.keyboardOwnedDy } };
27+
}
28+
return node;
29+
}),
30+
);
5131
}
5232

5333
function tappedDevice(snapshot: SnapshotState, calls: Point[]) {
@@ -137,15 +117,9 @@ test('no keyboard in the tree means nothing to refuse', async () => {
137117
assert.deepEqual(calls, [{ x: 200, y: 833 }]);
138118
});
139119

140-
test('an app-drawn keypad in the upper half is not the system keyboard', async () => {
120+
test('an app-drawn keypad that stops short of the bottom edge is not the system keyboard', async () => {
141121
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);
122+
const device = tappedDevice(keyboardTree({ keyboardOwnedDy: -560 }), calls);
149123

150124
await device.interactions.click(ref('@e2'), { session: 'default' });
151125

@@ -168,6 +142,58 @@ test('a coordinate behind the keyboard taps anyway and discloses the reason', as
168142
assert.match(result.warning ?? '', /tap_keyboard_occludes_target/);
169143
});
170144

145+
test('the guard reads the point the interaction dispatches, not the rect center', async () => {
146+
const calls: Point[] = [];
147+
// The Form button's own center sits 3 pt above the key plane, but its interactive child owns that
148+
// upper region, so the aim point the tap dispatches is pushed down into the keyboard's band.
149+
const aimShifted = makeSnapshotState([
150+
...keyboardCoveredTabBarSnapshot().nodes,
151+
{
152+
index: 5,
153+
depth: 3,
154+
parentIndex: 1,
155+
type: 'Button',
156+
label: 'Send',
157+
rect: { x: 148, y: 500, width: 104, height: 83 },
158+
hittable: true,
159+
},
160+
]);
161+
const shifted = aimShifted.nodes.map((node) =>
162+
node.index === 1 ? { ...node, rect: { x: 148, y: 500, width: 104, height: 160 } } : node,
163+
);
164+
const device = tappedDevice(makeSnapshotState(shifted), calls);
165+
166+
await assert.rejects(
167+
() => device.interactions.click(ref('@e2'), { session: 'default' }),
168+
(error: unknown) => {
169+
assert.ok(error instanceof Error);
170+
assert.match(error.message, /Ref @e2 is behind the visible keyboard/);
171+
return true;
172+
},
173+
);
174+
assert.deepEqual(calls, []);
175+
176+
// The same tree with its keypad lifted off the bottom edge shows where that tap was aiming: below
177+
// the key plane, which is what makes this the aim point's refusal rather than the center's.
178+
const aimCalls: Point[] = [];
179+
const clearDevice = tappedDevice(
180+
makeSnapshotState(
181+
shifted.map((node) =>
182+
node.type === 'Keyboard' || node.type === 'Key'
183+
? { ...node, rect: { ...node.rect!, y: node.rect!.y - 560 } }
184+
: node,
185+
),
186+
),
187+
aimCalls,
188+
);
189+
await clearDevice.interactions.click(ref('@e2'), { session: 'default' });
190+
assert.equal(aimCalls.length, 1);
191+
assert.ok(
192+
(aimCalls[0]?.y ?? 0) > 583,
193+
`expected the dispatched aim below the key plane at 583, got ${aimCalls[0]?.y}`,
194+
);
195+
});
196+
171197
test('a coordinate on a reported key is the keyboard the caller asked for', async () => {
172198
const calls: Point[] = [];
173199
const device = tappedDevice(keyboardTree(), calls);

src/commands/interaction/runtime/keyboard-occlusion.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { Point, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
1+
import type { Point, Rect, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
22
import { AppError } from '@agent-device/kernel/errors';
3-
import { resolveRectCenter } from '@agent-device/kernel/rect-center';
43
import { createSnapshotVisibility } from '@agent-device/contracts/snapshot';
54
import {
65
resolveKeyboardTapOcclusion,
@@ -31,11 +30,16 @@ export function assertTapTargetClearOfVisibleKeyboard(params: {
3130
action: InteractionAction;
3231
/** How the caller named the target, e.g. `Ref @e40` or `Selector text=Form`. */
3332
label: string;
33+
/**
34+
* Where this interaction aims: the point a point-dispatching path resolved through the same
35+
* resolver it dispatches with, or the rect center for the native-ref fast path, which hands the
36+
* element to the platform and lets it pick. Null when the node has no measurable aim.
37+
*/
38+
tapPoint: Point | null;
3439
}): void {
3540
const targetRect = params.node.rect;
36-
if (!targetRect) return;
37-
const tapPoint = resolveRectCenter(targetRect);
38-
if (!tapPoint) return;
41+
const tapPoint = params.tapPoint;
42+
if (!targetRect || !tapPoint) return;
3943
const occlusion = resolveKeyboardTapOcclusion({
4044
nodes: params.nodes,
4145
viewport: createSnapshotVisibility(params.nodes).resolveViewport(targetRect),
@@ -62,15 +66,12 @@ export function assertTapTargetClearOfVisibleKeyboard(params: {
6266
export function describeKeyboardOccludedPointWarning(params: {
6367
nodes: SnapshotState['nodes'];
6468
point: Point;
69+
/** The caller's own viewport lookup for this point, shared with the viewport warning above it. */
70+
viewport: Rect | null;
6571
}): string | undefined {
6672
const occlusion = resolveKeyboardTapOcclusion({
6773
nodes: params.nodes,
68-
viewport: createSnapshotVisibility(params.nodes).resolveViewport({
69-
x: params.point.x,
70-
y: params.point.y,
71-
width: 0,
72-
height: 0,
73-
}),
74+
viewport: params.viewport,
7475
point: params.point,
7576
});
7677
if (occlusion.kind !== 'occluded') return undefined;

src/commands/interaction/runtime/resolution.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ async function resolvePointTargetWarning(
229229
if (viewport && !containsPoint(viewport, point.x, point.y)) {
230230
return `Coordinates (${point.x}, ${point.y}) are outside the last-known viewport (${viewport.width}x${viewport.height}). The tap will be forwarded anyway; take a fresh snapshot if the screen changed.`;
231231
}
232-
return describeKeyboardOccludedPointWarning({ nodes, point });
232+
return describeKeyboardOccludedPointWarning({ nodes, point, viewport });
233233
}
234234

235235
async function resolvePointInteractionTarget(
@@ -331,7 +331,7 @@ async function resolveRefInteractionTarget(
331331
: await readRefResolution(runtime, options, target);
332332
const nodes = tree.nodes;
333333
// #1542: point/response read from the returned (possibly rescue-patched) node.
334-
const visibleNode = await runInteractionPipelineStages({
334+
const { node: visibleNode, tapPoint: point } = await runInteractionPipelineStages({
335335
policy: params.pipeline,
336336
nodes,
337337
node: resolved.node,
@@ -342,11 +342,12 @@ async function resolveRefInteractionTarget(
342342
offscreen: async (node, tree) =>
343343
await assertVisibleRefTarget(runtime, options, node, tree, target.ref, params),
344344
},
345-
});
346-
const point = resolveNodeTouchPoint(visibleNode, nodes, {
347-
invalidMessage: `Ref ${target.ref} not found or has invalid bounds`,
348-
blockedTargetLabel: `Ref ${target.ref}`,
349-
blockedTargetDetails: { ref: `@${normalizeRef(target.ref) ?? visibleNode.ref}` },
345+
resolveTapPoint: (node) =>
346+
resolveNodeTouchPoint(node, nodes, {
347+
invalidMessage: `Ref ${target.ref} not found or has invalid bounds`,
348+
blockedTargetLabel: `Ref ${target.ref}`,
349+
blockedTargetDetails: { ref: `@${normalizeRef(target.ref) ?? node.ref}` },
350+
}),
350351
});
351352
return {
352353
kind: 'ref',
@@ -396,7 +397,7 @@ async function resolveSelectorInteractionTarget(
396397
}
397398
// #1542: see the ref-target twin above.
398399
const selected = resolved;
399-
const visibleNode = await runInteractionPipelineStages({
400+
const { node: visibleNode, tapPoint: point } = await runInteractionPipelineStages({
400401
policy: params.pipeline,
401402
nodes: capture.snapshot.nodes,
402403
node: selected.node,
@@ -407,11 +408,12 @@ async function resolveSelectorInteractionTarget(
407408
offscreen: async (node, tree) =>
408409
await assertVisibleSelectorTarget(runtime, options, node, tree, selected.selector, params),
409410
},
410-
});
411-
const point = resolveNodeTouchPoint(visibleNode, capture.snapshot.nodes, {
412-
invalidMessage: `Selector ${resolved.selector} resolved to invalid bounds`,
413-
blockedTargetLabel: `Selector ${selectorExpression}`,
414-
blockedTargetDetails: { selector: selectorExpression },
411+
resolveTapPoint: (node) =>
412+
resolveNodeTouchPoint(node, capture.snapshot.nodes, {
413+
invalidMessage: `Selector ${resolved.selector} resolved to invalid bounds`,
414+
blockedTargetLabel: `Selector ${selectorExpression}`,
415+
blockedTargetDetails: { selector: selectorExpression },
416+
}),
415417
});
416418
return {
417419
kind: 'selector',
@@ -651,15 +653,31 @@ function describeNonHittableTarget(
651653
* interaction runtime owns. Which stages run is the row's decision; every acting path — selector,
652654
* ref, and the native-ref preflight — enters them here, which is what keeps the native-ref fast
653655
* path from succeeding on a target the shared rules would refuse.
656+
*
657+
* A path that dispatches a coordinate hands in its own aim resolver and gets the measured point
658+
* back, so the keyboard guard reads the coordinate the interaction will actually send and the
659+
* dispatch does not derive a second one. The native-ref fast path dispatches by ref and has no
660+
* point to measure: its guard reads the rect center, which is the aim the platform picks.
654661
*/
655-
async function runInteractionPipelineStages(params: {
662+
type InteractionStageParams = {
656663
policy: SelectorPipelinePolicy;
657664
nodes: SnapshotState['nodes'];
658665
node: SnapshotNode;
659666
action: InteractionAction;
660667
label: string;
661668
hooks: SelectorPipelineHooks;
662-
}): Promise<SnapshotNode> {
669+
};
670+
type InteractionStageOutcome = { node: SnapshotNode };
671+
672+
async function runInteractionPipelineStages(
673+
params: InteractionStageParams & { resolveTapPoint: (node: SnapshotNode) => Point },
674+
): Promise<InteractionStageOutcome & { tapPoint: Point }>;
675+
async function runInteractionPipelineStages(
676+
params: InteractionStageParams & { resolveTapPoint?: undefined },
677+
): Promise<InteractionStageOutcome & { tapPoint: null }>;
678+
async function runInteractionPipelineStages(
679+
params: InteractionStageParams & { resolveTapPoint?: (node: SnapshotNode) => Point },
680+
): Promise<InteractionStageOutcome & { tapPoint: Point | null }> {
663681
const target = await runNodePipelineStages(
664682
params.policy,
665683
params.nodes,
@@ -673,13 +691,17 @@ async function runInteractionPipelineStages(params: {
673691
action: params.action,
674692
});
675693
}
694+
const tapPoint = params.resolveTapPoint
695+
? params.resolveTapPoint(target.node)
696+
: (resolveRectCenter(target.node.rect) ?? null);
676697
assertTapTargetClearOfVisibleKeyboard({
677698
nodes: params.nodes,
678699
node: target.node,
679700
action: params.action,
680701
label: params.label,
702+
tapPoint,
681703
});
682-
return target.node;
704+
return { node: target.node, tapPoint };
683705
}
684706

685707
function buildCoveredInteractionError(params: {
@@ -1001,7 +1023,7 @@ export async function preflightNativeRefInteraction(
10011023
const pipeline = SELECTOR_PIPELINE_POLICIES.resolvedTarget;
10021024
// #1542: dispatches by REF, not coordinate, so no point to re-derive — but
10031025
// evidence/annotation below still describes the returned (visible) node.
1004-
const visibleNode = await runInteractionPipelineStages({
1026+
const { node: visibleNode } = await runInteractionPipelineStages({
10051027
policy: pipeline,
10061028
nodes,
10071029
node: resolved.node,

0 commit comments

Comments
 (0)