From 52dbc1bee2f725a9d54abd099828b1363dc1b8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 15:49:29 +0200 Subject: [PATCH] fix(ios): stop requesting tap synthesis the runner cannot perform on visionOS isIosFamily includes visionOS, so every producer of synthesized:true kept sending it for visionOS taps even though the runner's synthesis path is gated behind #if os(iOS) and falls through to an unsupported-message XCTest fallback on visionOS. Add runnerSynthesizesTap, a named predicate for the leaves the runner actually synthesizes on (iOS and iPadOS), and route every synthesized:true producer through it, including the runner-sequence press-series builder. --- packages/kernel/src/device.ts | 13 ++++ .../core/__tests__/apple-core-stub-helpers.ts | 9 +++ .../src/core/__tests__/interactions.test.ts | 72 +++++++++++++++++++ packages/platform-apple/src/interactions.ts | 16 ++--- .../src/runner/runner-sequence.ts | 4 +- src/core/__tests__/device.test.ts | 14 ++++ 6 files changed, 118 insertions(+), 10 deletions(-) diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index 27501cf043..4aeaa40641 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -178,6 +178,19 @@ export function isTvOsDevice(device: Pick): b return isApplePlatform(device.platform) && device.target === 'tv'; } +/** + * The Apple leaves whose runner synthesizes tap input (`RunnerTests+SynthesizedInteraction.swift` + * gates two-finger HID synthesis behind `#if os(iOS)`, which covers iOS and iPadOS only): every + * `isIosFamily` leaf except tvOS (no touchscreen) and visionOS (the runner's `#else` branch, no + * synthesis path). Every producer of `synthesized: true` gates on this predicate so none of them + * pays for a synthesis attempt the runner cannot perform. + */ +export function runnerSynthesizesTap( + device: Pick, +): boolean { + return isIosFamily(device) && !isTvOsDevice(device) && device.appleOs !== 'visionos'; +} + /** Resolve the stored Apple OS, preserving legacy target/leaf inference for old device records. */ export function resolveDeviceAppleOs( device: Pick, diff --git a/packages/platform-apple/src/core/__tests__/apple-core-stub-helpers.ts b/packages/platform-apple/src/core/__tests__/apple-core-stub-helpers.ts index 2b632fabb6..767c608f68 100644 --- a/packages/platform-apple/src/core/__tests__/apple-core-stub-helpers.ts +++ b/packages/platform-apple/src/core/__tests__/apple-core-stub-helpers.ts @@ -34,3 +34,12 @@ export const TVOS_TEST_SIMULATOR: DeviceInfo = { target: 'tv', booted: true, }; + +export const VISIONOS_TEST_SIMULATOR: DeviceInfo = { + platform: 'apple', + appleOs: 'visionos', + id: 'visionos-sim-1', + name: 'Apple Vision Pro', + kind: 'simulator', + booted: true, +}; diff --git a/packages/platform-apple/src/core/__tests__/interactions.test.ts b/packages/platform-apple/src/core/__tests__/interactions.test.ts index b07de9e36d..2834331578 100644 --- a/packages/platform-apple/src/core/__tests__/interactions.test.ts +++ b/packages/platform-apple/src/core/__tests__/interactions.test.ts @@ -21,6 +21,7 @@ import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE, TVOS_TEST_SIMULATOR, + VISIONOS_TEST_SIMULATOR, } from './apple-core-stub-helpers.ts'; vi.mock('../runner-client.ts', async (importOriginal) => { @@ -141,6 +142,77 @@ test('iosRunnerOverrides uses synthesized iOS coordinate taps for selectors', as }); }); +test('iosRunnerOverrides does not request synthesis for visionOS coordinate taps', async () => { + mockRunAppleRunnerCommand.mockResolvedValue({}); + + const { overrides } = iosRunnerOverrides(VISIONOS_TEST_SIMULATOR, { + appBundleId: 'com.example.App', + }); + + await overrides.tap(100, 200); + + assert.deepEqual(mockRunAppleRunnerCommand.mock.calls[0]?.[1], { + command: 'tap', + x: 100, + y: 200, + appBundleId: 'com.example.App', + }); +}); + +test('iosRunnerOverrides does not request synthesis for visionOS selector taps', async () => { + mockRunAppleRunnerCommand.mockResolvedValue({}); + + const { overrides } = iosRunnerOverrides(VISIONOS_TEST_SIMULATOR, { + appBundleId: 'com.example.App', + }); + + await overrides.tapElementSelector!({ + key: 'label', + value: 'General', + expectedPoint: { x: 200, y: 300 }, + }); + + assert.deepEqual(mockRunAppleRunnerCommand.mock.calls[0]?.[1], { + command: 'tap', + selectorKey: 'label', + selectorValue: 'General', + allowNonHittableCoordinateFallback: undefined, + x: 200, + y: 300, + appBundleId: 'com.example.App', + }); +}); + +test('iosRunnerOverrides does not request synthesis for visionOS fused presses', async () => { + mockRunAppleRunnerCommand.mockResolvedValue({ + completedSteps: 3, + sequenceResults: Array.from({ length: 3 }, () => ({ ok: true, kind: 'tap' })), + }); + const { overrides } = iosRunnerOverrides(VISIONOS_TEST_SIMULATOR, { + appBundleId: 'com.example.App', + }); + + await overrides.pressPoint!( + { x: 100, y: 200 }, + { + button: 'primary', + count: 3, + intervalMs: 40, + holdMs: 0, + jitterPx: 2, + doubleTap: false, + }, + ); + + const command = mockRunAppleRunnerCommand.mock.calls[0]?.[1] as RunnerCommand; + assert.equal(command.command, 'sequence'); + assert.deepEqual(command.steps, [ + { kind: 'tap', x: 100, y: 200, pauseMs: 40 }, + { kind: 'tap', x: 102, y: 200, pauseMs: 40 }, + { kind: 'tap', x: 100, y: 202 }, + ]); +}); + test('iosRunnerOverrides owns fused repeated presses with deterministic jitter', async () => { mockRunAppleRunnerCommand.mockResolvedValue({ completedSteps: 3, diff --git a/packages/platform-apple/src/interactions.ts b/packages/platform-apple/src/interactions.ts index 0c61d7486a..e6048040a0 100644 --- a/packages/platform-apple/src/interactions.ts +++ b/packages/platform-apple/src/interactions.ts @@ -19,7 +19,12 @@ import { assertScrollGestureInput, } from '@agent-device/contracts/scroll-gesture'; import { assertAppleMultiTouchSupported } from './multitouch-support.ts'; -import { isIosFamily, isMacOs, isTvOsDevice, type DeviceInfo } from '@agent-device/kernel/device'; +import { + isMacOs, + isTvOsDevice, + runnerSynthesizesTap, + type DeviceInfo, +} from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { runAppleRunnerCommand, runApplePressSeries } from './core/runner-client.ts'; import { @@ -99,7 +104,7 @@ export function iosRunnerOverrides( ...(selector.expectedPoint ? { x: selector.expectedPoint.x, y: selector.expectedPoint.y } : {}), - ...(shouldUseSynthesizedIosGesture(device) ? { synthesized: true } : {}), + ...(runnerSynthesizesTap(device) ? { synthesized: true } : {}), appBundleId: ctx.appBundleId, }, runnerOpts, @@ -386,16 +391,11 @@ function iosTapCommand( command: 'tap', x, y, - ...(shouldUseSynthesizedIosGesture(device) ? { synthesized: true } : {}), + ...(runnerSynthesizesTap(device) ? { synthesized: true } : {}), appBundleId: ctx.appBundleId, }; } -function shouldUseSynthesizedIosGesture(device: DeviceInfo): boolean { - // Two-finger HID synthesis is for touch-input iOS only; the tvOS leaf has no touch. - return isIosFamily(device) && !isTvOsDevice(device); -} - async function runAppleScroll( runRunnerCommand: RunAppleRunnerCommand, device: DeviceInfo, diff --git a/packages/platform-apple/src/runner/runner-sequence.ts b/packages/platform-apple/src/runner/runner-sequence.ts index 48fe8325fb..773b005907 100644 --- a/packages/platform-apple/src/runner/runner-sequence.ts +++ b/packages/platform-apple/src/runner/runner-sequence.ts @@ -1,6 +1,6 @@ import type { PressPointOptions } from '@agent-device/contracts/interactor-types'; import { pressJitter } from '@agent-device/contracts/touch-runtime'; -import { isIosFamily, isTvOsDevice, type DeviceInfo } from '@agent-device/kernel/device'; +import { runnerSynthesizesTap, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; import type { RunnerCommand, RunnerSequenceStep } from './runner-contract.ts'; @@ -216,7 +216,7 @@ function buildPressSteps( options: PressPointOptions, ): RunnerSequenceStep[] { const kind = options.doubleTap ? 'doubleTap' : options.holdMs > 0 ? 'longPress' : 'tap'; - const synthesized = kind === 'tap' && isIosFamily(device) && !isTvOsDevice(device); + const synthesized = kind === 'tap' && runnerSynthesizesTap(device); return Array.from({ length: options.count }, (_, index) => { const [dx, dy] = pressJitter(index, options.jitterPx); return { diff --git a/src/core/__tests__/device.test.ts b/src/core/__tests__/device.test.ts index abf4c02386..a4b0f306b6 100644 --- a/src/core/__tests__/device.test.ts +++ b/src/core/__tests__/device.test.ts @@ -8,13 +8,16 @@ import { resolveApplePlatformName, resolveAppleSimulatorSetPathForSelector, resolveDevice, + runnerSynthesizesTap, } from '@agent-device/kernel/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { ANDROID_TV_DEVICE, IOS_SIMULATOR, + IPADOS_SIMULATOR, MACOS_DEVICE, TVOS_SIMULATOR, + VISIONOS_SIMULATOR, } from '../../__tests__/test-utils/device-fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; @@ -28,6 +31,17 @@ test('isTvOsDevice selects only the Apple tvOS leaf, not any TV target', () => { assert.equal(isTvOsDevice(ANDROID_TV_DEVICE), false); }); +test('runnerSynthesizesTap selects only the leaves whose runner has a synthesis path', () => { + // iOS and iPadOS: the runner's `#if os(iOS)` branch synthesizes. + assert.equal(runnerSynthesizesTap(IOS_SIMULATOR), true); + assert.equal(runnerSynthesizesTap(IPADOS_SIMULATOR), true); + // tvOS has no touchscreen; visionOS's runner has no synthesis path (falls to the + // Swift `#else` branch) — neither may carry `synthesized: true`. + assert.equal(runnerSynthesizesTap(TVOS_SIMULATOR), false); + assert.equal(runnerSynthesizesTap(VISIONOS_SIMULATOR), false); + assert.equal(runnerSynthesizesTap(MACOS_DEVICE), false); +}); + test('matchesPlatformSelector resolves apple selector across Apple platforms', () => { assert.equal(matchesPlatformSelector({ platform: 'apple', appleOs: 'ios' }, 'apple'), true); assert.equal(matchesPlatformSelector({ platform: 'apple', appleOs: 'macos' }, 'apple'), true);