Skip to content

Commit 8f1e50b

Browse files
committed
fix(selectors): keep the wrapper-collapse fallback to non-actionable wrappers
Review follow-up on #2482. The unverified-hittability collapse accepted any ancestry chain whose rects agreed within a point, so a cell and the button inside it (both actionable, no hittability evidence) collapsed to the descendant: a silent wrong-control press where the previous rules refused as ambiguous. The fallback now requires every candidate above the control to be a non-actionable wrapper, and a negative regression covers the semantic-ancestor case next to the captured Other/Button success case. Gate: pnpm check:affected --run - 304 files / 2011 tests, all runnable checks passed.
1 parent 3dce49d commit 8f1e50b

2 files changed

Lines changed: 41 additions & 3 deletions

File tree

packages/selectors/src/interaction-targeting-wrapper-chain.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,36 @@ test('refuses a chain whose rects differ beyond sub-pixel slack', () => {
6868
);
6969
});
7070

71+
test('refuses a chain of two real controls that share one rect', () => {
72+
// A cell and the button inside it share an identifier and their rects agree
73+
// within slack. Both are actionable, so collapsing to the descendant would
74+
// silently press the wrong control; the ambiguity refusal must survive.
75+
const snapshot = makeSnapshotState([
76+
{
77+
index: 0,
78+
depth: 1,
79+
type: 'XCUIElementTypeCell',
80+
identifier: 'row_action',
81+
rect: { x: 20, y: 63, width: 36, height: 36 },
82+
},
83+
{
84+
index: 1,
85+
depth: 2,
86+
parentIndex: 0,
87+
type: 'XCUIElementTypeButton',
88+
identifier: 'row_action',
89+
rect: { x: 20.5, y: 63, width: 35, height: 36 },
90+
},
91+
]);
92+
93+
assert.equal(
94+
resolveUnverifiedWrapperControl(
95+
snapshot.nodes.filter((node) => node.identifier === 'row_action'),
96+
),
97+
null,
98+
);
99+
});
100+
71101
test('refuses a chain whose deepest candidate is not a semantic touch target', () => {
72102
const snapshot = makeSnapshotState([
73103
{

packages/selectors/src/interaction-targeting-wrapper-chain.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ import { isSemanticTouchTarget } from './touch-semantics.ts';
77
const WRAPPER_RECT_SLACK = 1;
88

99
/**
10-
* The deepest semantic touch target of a single ancestry chain whose candidates
11-
* all lack hittability evidence, or null when the chain does not denote one
10+
* The control of a single ancestry chain that is one actionable control wrapped
11+
* by non-actionable wrappers, or null when the chain does not denote one
1212
* control.
1313
*
1414
* Regular iOS snapshots omit unverified hittability, and
1515
* `findPreferredActionableDescendant` requires verified hittability, so a
1616
* SwiftUI wrapper can never relate to its own control through the resolution
1717
* ladder: press and wait then see two actionable elements for one toolbar
18-
* button. Candidates carrying any hittability fact keep the existing rules.
18+
* button. The collapse stays narrow on purpose: every candidate above the
19+
* control must be a non-actionable wrapper, so a chain of two real controls (a
20+
* cell and the button inside it) keeps the existing ambiguity refusal instead of
21+
* silently pressing the descendant. Candidates carrying any hittability fact
22+
* also keep the existing rules.
1923
*/
2024
export function resolveUnverifiedWrapperControl(
2125
candidates: readonly SnapshotNode[],
@@ -26,6 +30,10 @@ export function resolveUnverifiedWrapperControl(
2630
(candidate.depth ?? 0) > (deepest.depth ?? 0) ? candidate : deepest,
2731
);
2832
if (!isSemanticTouchTarget(control)) return null;
33+
const wrapsOnlyNonActionable = candidates.every(
34+
(candidate) => candidate === control || !isSemanticTouchTarget(candidate),
35+
);
36+
if (!wrapsOnlyNonActionable) return null;
2937
const controlRect = normalizeRect(control.rect);
3038
if (!controlRect) return null;
3139
return candidates.every((candidate) =>

0 commit comments

Comments
 (0)