Skip to content

Commit 9b55929

Browse files
authored
fix: preserve iOS tab button tap centers (#508)
1 parent 274eeb5 commit 9b55929

2 files changed

Lines changed: 192 additions & 1 deletion

File tree

src/__tests__/runtime-interactions.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,61 @@ test('runtime press resolves selector targets to the actionable node center', as
6767
assert.deepEqual(result.backendResult, { ok: true });
6868
});
6969

70+
test('runtime click keeps distinct tab button centers when iOS reports the tab bar as hittable', async () => {
71+
const calls: Point[] = [];
72+
const device = createInteractionDevice(iosTabBarSnapshot(), {
73+
tap: async (_context, point) => {
74+
calls.push(point);
75+
},
76+
});
77+
78+
const refResult = await device.interactions.click(ref('@e4'), {
79+
session: 'default',
80+
});
81+
const selectorResult = await device.interactions.click(selector('label=Settings'), {
82+
session: 'default',
83+
});
84+
85+
assert.deepEqual(calls, [
86+
{ x: 166, y: 822 },
87+
{ x: 257, y: 822 },
88+
]);
89+
assert.equal(refResult.kind, 'ref');
90+
assert.equal(refResult.node?.label, 'Library');
91+
assert.equal(selectorResult.kind, 'selector');
92+
assert.equal(selectorResult.node?.label, 'Settings');
93+
});
94+
95+
test('runtime click keeps non-button semantic targets at their own center', async () => {
96+
const calls: Point[] = [];
97+
const device = createInteractionDevice(nonHittableCellSnapshot(), {
98+
tap: async (_context, point) => {
99+
calls.push(point);
100+
},
101+
});
102+
103+
const result = await clickRefE2(device);
104+
105+
assert.deepEqual(calls, [{ x: 70, y: 30 }]);
106+
assert.equal(result.kind, 'ref');
107+
assert.equal(result.node?.label, 'Account');
108+
});
109+
110+
test('runtime click still promotes non-touchable nodes to hittable ancestors', async () => {
111+
const calls: Point[] = [];
112+
const device = createInteractionDevice(nonTouchableGroupSnapshot(), {
113+
tap: async (_context, point) => {
114+
calls.push(point);
115+
},
116+
});
117+
118+
const result = await clickRefE2(device);
119+
120+
assert.deepEqual(calls, [{ x: 160, y: 60 }]);
121+
assert.equal(result.kind, 'ref');
122+
assert.equal(result.node?.label, 'Clickable group');
123+
});
124+
70125
test('runtime fill resolves refs and forwards text to the backend primitive', async () => {
71126
const calls: Array<{ point: Point; text: string; delayMs?: number }> = [];
72127
const device = createInteractionDevice(fillableSnapshot(), {
@@ -422,6 +477,107 @@ function fillableSnapshot(): SnapshotState {
422477
]);
423478
}
424479

480+
function iosTabBarSnapshot(): SnapshotState {
481+
return makeSnapshotState([
482+
{
483+
index: 0,
484+
depth: 0,
485+
type: 'XCUIElementTypeApplication',
486+
label: 'TabRepro',
487+
rect: { x: 0, y: 0, width: 402, height: 874 },
488+
hittable: false,
489+
},
490+
{
491+
index: 1,
492+
depth: 1,
493+
parentIndex: 0,
494+
type: 'XCUIElementTypeTabBar',
495+
rect: { x: 0, y: 791, width: 402, height: 83 },
496+
hittable: true,
497+
},
498+
{
499+
index: 2,
500+
depth: 2,
501+
parentIndex: 1,
502+
type: 'XCUIElementTypeButton',
503+
label: 'Home',
504+
rect: { x: 30, y: 800, width: 91, height: 44 },
505+
hittable: false,
506+
},
507+
{
508+
index: 3,
509+
depth: 2,
510+
parentIndex: 1,
511+
type: 'XCUIElementTypeButton',
512+
label: 'Library',
513+
rect: { x: 120, y: 800, width: 92, height: 44 },
514+
hittable: false,
515+
},
516+
{
517+
index: 4,
518+
depth: 2,
519+
parentIndex: 1,
520+
type: 'XCUIElementTypeButton',
521+
label: 'Settings',
522+
rect: { x: 211, y: 800, width: 91, height: 44 },
523+
hittable: false,
524+
},
525+
{
526+
index: 5,
527+
depth: 2,
528+
parentIndex: 1,
529+
type: 'XCUIElementTypeButton',
530+
label: 'Search',
531+
rect: { x: 304, y: 800, width: 92, height: 44 },
532+
hittable: false,
533+
},
534+
]);
535+
}
536+
537+
function nonHittableCellSnapshot(): SnapshotState {
538+
return makeSnapshotState([
539+
{
540+
index: 0,
541+
depth: 0,
542+
type: 'XCUIElementTypeOther',
543+
label: 'Settings list',
544+
rect: { x: 10, y: 20, width: 300, height: 80 },
545+
hittable: true,
546+
},
547+
{
548+
index: 1,
549+
depth: 1,
550+
parentIndex: 0,
551+
type: 'XCUIElementTypeCell',
552+
label: 'Account',
553+
rect: { x: 20, y: 10, width: 100, height: 40 },
554+
hittable: false,
555+
},
556+
]);
557+
}
558+
559+
function nonTouchableGroupSnapshot(): SnapshotState {
560+
return makeSnapshotState([
561+
{
562+
index: 0,
563+
depth: 0,
564+
type: 'XCUIElementTypeOther',
565+
label: 'Clickable group',
566+
rect: { x: 10, y: 20, width: 300, height: 80 },
567+
hittable: true,
568+
},
569+
{
570+
index: 1,
571+
depth: 1,
572+
parentIndex: 0,
573+
type: 'XCUIElementTypeOther',
574+
label: 'Decorative group',
575+
rect: { x: 30, y: 40, width: 60, height: 20 },
576+
hittable: false,
577+
},
578+
]);
579+
}
580+
425581
function snapshotWithOffscreenContent(): SnapshotState {
426582
return makeSnapshotState([
427583
{
@@ -495,3 +651,9 @@ function createInteractionDevice(
495651
policy: localCommandPolicy(),
496652
});
497653
}
654+
655+
async function clickRefE2(device: ReturnType<typeof createInteractionDevice>) {
656+
return await device.interactions.click(ref('@e2'), {
657+
session: 'default',
658+
});
659+
}

src/commands/interaction-targeting.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import type { Rect, SnapshotNode } from '../utils/snapshot.ts';
22
import { centerOfRect } from '../utils/snapshot.ts';
33
import { containsPoint, pickLargestRect } from '../utils/rect-visibility.ts';
4-
import { findNearestHittableAncestor } from '../utils/snapshot-processing.ts';
4+
import { findNearestHittableAncestor, normalizeType } from '../utils/snapshot-processing.ts';
55
import { normalizeRect, resolveRectCenter } from '../utils/rect-center.ts';
66

7+
const SEMANTIC_TOUCH_ROLE_FRAGMENTS = [
8+
'button',
9+
'link',
10+
'menuitem',
11+
'tabitem',
12+
'textfield',
13+
'searchfield',
14+
'securetextfield',
15+
'checkbox',
16+
'radio',
17+
'switch',
18+
'cell',
19+
];
20+
721
export function resolveActionableTouchNode(
822
nodes: SnapshotNode[],
923
node: SnapshotNode,
@@ -12,6 +26,9 @@ export function resolveActionableTouchNode(
1226
if (descendant?.rect && resolveRectCenter(descendant.rect)) {
1327
return descendant;
1428
}
29+
if (isSemanticallyTouchableNode(node) && node.rect && resolveRectCenter(node.rect)) {
30+
return node;
31+
}
1532
const ancestor = findNearestHittableAncestor(nodes, node);
1633
if (ancestor?.rect && resolveRectCenter(ancestor.rect)) {
1734
if (isOverlyBroadAncestor(node, ancestor, nodes)) {
@@ -49,6 +66,18 @@ function findPreferredActionableDescendant(
4966
return current === node ? null : current;
5067
}
5168

69+
function isSemanticallyTouchableNode(node: SnapshotNode): boolean {
70+
const roles = [node.type, node.role, node.subrole].map((value) => normalizeType(value ?? ''));
71+
return roles.some(isSemanticTouchRole);
72+
}
73+
74+
function isSemanticTouchRole(role: string): boolean {
75+
// Match Tab exactly so broad roles like Table/TabBar do not become touch targets.
76+
return (
77+
role === 'tab' || SEMANTIC_TOUCH_ROLE_FRAGMENTS.some((fragment) => role.includes(fragment))
78+
);
79+
}
80+
5281
function areRectsApproximatelyEqual(left: Rect, right: Rect): boolean {
5382
const tolerance = 0.5;
5483
return (

0 commit comments

Comments
 (0)