diff --git a/packages/selectors/src/interaction-touch-point.test.ts b/packages/selectors/src/interaction-touch-point.test.ts index 5395518a67..84ec802482 100644 --- a/packages/selectors/src/interaction-touch-point.test.ts +++ b/packages/selectors/src/interaction-touch-point.test.ts @@ -115,6 +115,37 @@ test('keeps the exact center when there are no competing interactive descendants }); }); +test.each<{ name: string; rect: Rect }>([ + { name: 'zero width and height', rect: { x: 0, y: 0, width: 0, height: 0 } }, + { name: 'zero width', rect: { x: 40, y: 120, width: 0, height: 60 } }, + { name: 'zero height', rect: { x: 40, y: 120, width: 60, height: 0 } }, +])('refuses a $name target instead of resolving its origin', ({ rect }) => { + const nodes = makeSnapshotState([ + { index: 0, depth: 0, type: 'Icon', label: 'Clock', rect, hittable: false }, + ]).nodes; + + assert.deepEqual(resolveInteractionTouchPoint(nodes, nodes[0]!), { kind: 'invalid' }); +}); + +test('still resolves the smallest positive-area target', () => { + const nodes = makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Button', + label: 'Dot', + rect: { x: 40, y: 120, width: 1, height: 1 }, + hittable: true, + }, + ]).nodes; + + assert.deepEqual(resolveInteractionTouchPoint(nodes, nodes[0]!), { + kind: 'resolved', + point: { x: 41, y: 121 }, + strategy: 'center', + }); +}); + test('keeps a parent-owned center in a thin row when a child is safely off to the side', () => { const nodes = makeSnapshotState([ { diff --git a/packages/selectors/src/interaction-touch-point.ts b/packages/selectors/src/interaction-touch-point.ts index d206ef325a..cdea66bc9e 100644 --- a/packages/selectors/src/interaction-touch-point.ts +++ b/packages/selectors/src/interaction-touch-point.ts @@ -35,7 +35,7 @@ export function resolveInteractionTouchPoint( ): InteractionTouchPointResolution { const targetRect = normalizeRect(node.rect); const center = resolveRectCenter(targetRect ?? undefined); - if (!targetRect || !center) return { kind: 'invalid' }; + if (!targetRect || !center || !hasArea(targetRect)) return { kind: 'invalid' }; const competitors = collectInteractiveDescendantRects(nodes, node, targetRect); if (competitors.length === 0) { @@ -59,6 +59,10 @@ export function resolveInteractionTouchPoint( return { kind: 'resolved', point, strategy: 'parent-owned' }; } +function hasArea(rect: Rect): boolean { + return rect.width > 0 && rect.height > 0; +} + function collectInteractiveDescendantRects( nodes: readonly SnapshotNode[], node: SnapshotNode, diff --git a/src/commands/interaction/runtime/resolution-touch-point.test.ts b/src/commands/interaction/runtime/resolution-touch-point.test.ts index 37538de861..075e64c9f2 100644 --- a/src/commands/interaction/runtime/resolution-touch-point.test.ts +++ b/src/commands/interaction/runtime/resolution-touch-point.test.ts @@ -79,3 +79,45 @@ test('runtime ref and selector paths dispatch the same parent-owned post point', assert.ok(refCalls[0]!.y < 292); assert.notDeepEqual(refCalls[0], { x: 201, y: 440 }); }); + +test('runtime press refuses a selector match with a zero-area rect and dispatches no tap', async () => { + const taps: Point[] = []; + // SpringBoard page 2 keeps page-1 icons in the tree as 0x0 rects at the origin. + const snapshot = makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + label: 'SpringBoard', + rect: { x: 0, y: 0, width: 402, height: 874 }, + hittable: true, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'Icon', + label: 'Clock', + rect: { x: 0, y: 0, width: 0, height: 0 }, + hittable: false, + }, + ]); + const device = createInteractionDevice(snapshot, { + tap: async (_context, point) => { + taps.push(point); + return { ok: true }; + }, + }); + + await assert.rejects( + () => device.interactions.press(selector('label="Clock"'), { session: 'default' }), + (error: unknown) => { + const details = (error as { details?: Record }).details; + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.equal(details?.reason, 'target_bounds_invalid'); + assert.equal(details?.dispatched, 'no'); + return true; + }, + ); + assert.deepEqual(taps, []); +});