Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0013-unified-gesture-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Platform adapters consume the canonical plan:
macOS lowers a one-contact plan to its drag executor.
tvOS lowers a one-contact plan to remote direction.
Core admission and the Apple adapter both consume the same shared multi-touch support policy.
Multi-touch remains capability-gated to iOS simulators.
Multi-touch is capability-gated to iOS and iPadOS, on simulators and physical devices, through the XCTest runner.

iOS deliberately retains three one-contact motion schedules behind the shared private-XCTest event bridge.
Apple's public drag API models the phases independently as an [initial hold, movement velocity, and destination hold](https://developer.apple.com/documentation/xcuiautomation/xcuicoordinate/press%28forduration%3Athendragto%3Awithvelocity%3Athenholdforduration%3A%29).
Expand Down
22 changes: 12 additions & 10 deletions packages/contracts/src/gesture-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,12 @@ export const TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT =

/** The hint the Android owner states for a TV target, which has no touch input at all. */
export const ANDROID_TV_MULTI_TOUCH_UNSUPPORTED_HINT =
'Android TV has no touch input — this gesture is supported on Android phones, tablets, and the iOS simulator only.';

/** The hint the Apple owner states for a physical iOS/iPadOS device. */
export const PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT =
'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.';
'Android TV has no touch input — this gesture is supported on Android phones, tablets, iOS, and iPadOS.';

/**
* How a refused cell names itself, reproducing every subject the retired admission produced.
*
* Four owner-specific subjects, then the plain platform name. The special cases resolve the
* Owner-specific subjects, then the plain platform name. The special cases resolve the
* Apple OS the way `assertAppleMultiTouchSupported` does; the default reads `appleOs` raw, the way
* the retired `gesturePlatformMessage` did — an Apple device with no declared OS therefore still
* reports `apple`, exactly as before.
Expand All @@ -54,15 +50,21 @@ function gestureRefusalSubject(device: DeviceInfo, tier: GestureRuntimeTier): st
}

/**
* The three owner-specific subjects two-contact synthesis produced, or `undefined` where the
* retired admission fell through to the plain platform name.
* The owner-specific subjects two-contact synthesis refusals use, or `undefined` for the plain
* platform name. iOS and iPadOS are named only for a provider refusal: the Apple owner admits
* multi-touch on simulators and devices, and the provider's hint says why it does not.
*/
function multiTouchRefusalSubject(device: DeviceInfo): string | undefined {
if (device.platform === 'android') return device.target === 'tv' ? 'Android TV' : undefined;
if (!isApplePlatform(device.platform)) return undefined;
const appleOs = resolveDeviceAppleOs(device);
if (appleOs === 'ios' || appleOs === 'ipados') return 'physical iOS devices';
if (appleOs === 'macos' || appleOs === 'tvos' || appleOs === 'visionos') {
if (
appleOs === 'ios' ||
appleOs === 'ipados' ||
appleOs === 'macos' ||
appleOs === 'tvos' ||
appleOs === 'visionos'
) {
return APPLE_OS_DISPLAY_NAMES[appleOs];
}
return undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/gesture-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type GestureViewportInput = Readonly<{
* - `performDirectionalFlingPlan` — `gesture fling --direction`, whose speed semantics Linux
* cannot honor even though it executes coordinate flings through its drag primitive.
* - `performMultiTouchGesturePlan` — pinch/rotate/transform and two-pointer pan. On Apple this is
* the two-finger XCTest synthesis, which is iOS/iPadOS **simulator** only.
* the two-finger XCTest synthesis, which runs on iOS/iPadOS simulators and physical devices.
* - `performTargetAuthoredDrag` — `gesture drag`, which needs an adapter preserving source hold,
* timed movement, and destination hold.
*/
Expand Down
55 changes: 39 additions & 16 deletions packages/daemon/src/core/gesture-runtime-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,49 +20,71 @@ const refusalCases = [
fact: {
available: false,
reason: 'unsupported-platform-leaf',
hint: 'Android TV has no touch input — this gesture is supported on Android phones, tablets, and the iOS simulator only.',
hint: 'Android TV has no touch input — this gesture is supported on Android phones, tablets, iOS, and iPadOS.',
},
hint: /Android TV has no touch input/,
message: 'gesture pinch is not supported on Android TV',
},
{
name: 'physical iOS',
name: 'tvOS',
device: {
platform: 'apple',
appleOs: 'tvos',
id: 'tvos-simulator',
name: 'Apple TV Simulator',
kind: 'simulator',
target: 'tv',
},
fact: {
available: false,
reason: 'unsupported-platform-leaf',
hint: 'tvOS does not support multi-touch gesture plans.',
},
hint: /tvOS/,
message: 'gesture pinch is not supported on tvOS',
},
{
// The Apple owner admits physical iOS; a provider without the XCTest runner can still refuse.
name: 'provider-refused physical iOS',
device: {
platform: 'apple',
appleOs: 'ios',
id: 'iphone-device',
id: 'ios-device',
name: 'iPhone',
kind: 'device',
target: 'mobile',
},
fact: {
available: false,
reason: 'unsupported-device-kind',
hint: 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.',
reason: 'unsupported-provider-mode',
hint: 'Two-finger gesture synthesis is not available on iOS devices reached through a WebDriver provider.',
},
hint: /physical iOS devices/,
hint: /WebDriver provider/,
message: 'gesture pinch is not supported on iOS',
},
{
name: 'tvOS',
name: 'provider-refused iPadOS',
device: {
platform: 'apple',
appleOs: 'tvos',
id: 'tvos-simulator',
name: 'Apple TV Simulator',
kind: 'simulator',
target: 'tv',
appleOs: 'ipados',
id: 'ipad-device',
name: 'iPad',
kind: 'device',
target: 'mobile',
},
fact: {
available: false,
reason: 'unsupported-platform-leaf',
hint: 'tvOS does not support multi-touch gesture plans.',
reason: 'unsupported-provider-mode',
hint: 'Two-finger gesture synthesis is not available on iOS devices reached through a WebDriver provider.',
},
hint: /tvOS/,
hint: /WebDriver provider/,
message: 'gesture pinch is not supported on iPadOS',
},
] as const;

test.each(refusalCases)(
'$name preserves multi-touch admission details without binding',
async ({ device, fact, hint }) => {
async ({ device, fact, hint, message }) => {
const target = device as DeviceInfo;
const owner = localRuntimeOwner(target.platform);
const base = createUnavailableRuntimeFactsForTest(target, owner);
Expand All @@ -89,6 +111,7 @@ test.each(refusalCases)(
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(resolved.response.error.message).toBe(message);
expect(resolved.response.error.hint).toMatch(hint);
}
expect(inspectFacts).toHaveBeenCalledOnce();
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-android/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ test('carries the retired Android TV hints verbatim', async () => {
expect(facts.operations.performMultiTouchGesturePlan).toEqual({
available: false,
reason: 'unsupported-platform-leaf',
hint: 'Android TV has no touch input — this gesture is supported on Android phones, tablets, and the iOS simulator only.',
hint: 'Android TV has no touch input — this gesture is supported on Android phones, tablets, iOS, and iPadOS.',
});
expect(facts.operations.performTargetAuthoredDrag).toMatchObject({
available: false,
Expand Down
15 changes: 6 additions & 9 deletions packages/platform-apple/src/gesture-facts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ test.each([
appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }),
true,
true,
false,
true,
true,
true,
true,
Expand All @@ -64,14 +64,8 @@ test.each([
},
);

test('carries the retired multi-touch hints verbatim on every Apple leaf that refused', async () => {
test('carries the retired multi-touch hints on every Apple leaf that refused', async () => {
const runtime = createApplePlatformRuntime(platformRuntimeHostFixture());
const physical = await runtime.inspectFacts(appleDevice({ kind: 'device' }));
expect(physical.operations.performMultiTouchGesturePlan).toEqual({
available: false,
reason: 'unsupported-device-kind',
hint: 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.',
});
const macos = await runtime.inspectFacts(leaves.macos);
expect(macos.operations.performMultiTouchGesturePlan).toMatchObject({
available: false,
Expand Down Expand Up @@ -107,5 +101,8 @@ test('binds only the gesture tiers the leaf admitted', async () => {
expect(simulator.operations.scrollDirection).toBeTypeOf('function');
const physical = await bind(appleDevice({ kind: 'device' }));
expect(physical.operations.performGesturePlan).toBeTypeOf('function');
expect(physical.operations.performMultiTouchGesturePlan).toBeUndefined();
expect(physical.operations.performMultiTouchGesturePlan).toBeTypeOf('function');
const macos = await bind(leaves.macos);
expect(macos.operations.performGesturePlan).toBeTypeOf('function');
expect(macos.operations.performMultiTouchGesturePlan).toBeUndefined();
});
12 changes: 2 additions & 10 deletions packages/platform-apple/src/gesture-facts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS } from './multitouch-support.ts';
import {
PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT,
TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT,
} from '@agent-device/contracts/gesture-admission';
import { TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT } from '@agent-device/contracts/gesture-admission';
import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime';
import type {
RuntimeOperationFact,
Expand All @@ -19,11 +16,6 @@ const gestureLeafUnavailable = Object.freeze({
const gestureKindUnavailable = unsupportedAppleDeviceKind(
'Gestures are supported only for Apple simulators and devices.',
);
const physicalIosMultiTouchUnavailable = Object.freeze({
available: false,
reason: 'unsupported-device-kind',
hint: PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT,
} as const);
const targetAuthoredDragUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
Expand Down Expand Up @@ -76,7 +68,7 @@ function appleMultiTouchGestureFact(device: DeviceInfo): RuntimeOperationFact {
...(hint === undefined ? {} : { hint }),
} as const);
}
return device.kind === 'simulator' ? available : physicalIosMultiTouchUnavailable;
return available;
}

function appleTargetAuthoredDragFact(device: DeviceInfo): RuntimeOperationFact {
Expand Down
31 changes: 24 additions & 7 deletions packages/platform-apple/src/interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import {
} from './interactions.ts';
import { runAppleRunnerCommand } from './core/runner-client.ts';
import { AppError } from '@agent-device/kernel/errors';
import {
gestureRefusalMessage,
PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT,
} from '@agent-device/contracts/gesture-admission';
import { gestureRefusalMessage } from '@agent-device/contracts/gesture-admission';
import { APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS } from './multitouch-support.ts';
import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types';
import type { RunnerCommand } from './runner/index.ts';
import { TEXT_ENTRY_ROUTES } from '@agent-device/contracts/interactor-types';
Expand Down Expand Up @@ -415,19 +413,38 @@ test('performGestureApple sends exact two-pointer pan samples through gesture',
});
});

test('performGestureApple sends two-pointer samples to a physical iOS device', async () => {
mockRunAppleRunnerCommand.mockResolvedValue({ transformed: true });
const plan = twoFingerPanPlan();

const result = await performGestureApple(
IOS_TEST_DEVICE,
{ appBundleId: 'com.example.App' },
{},
plan,
);

assert.deepEqual(result, { transformed: true });
assert.deepEqual(mockRunAppleRunnerCommand.mock.calls[0]?.[1], {
command: 'gesture',
gesturePlan: plan,
appBundleId: 'com.example.App',
});
});

test('Apple admission and execution share the same multi-touch refusal', async () => {
// Admission is now the Apple owner's `performMultiTouchGesturePlan` fact composed with the
// shared refusal wording (R42); execution keeps its defensive adapter check. The two must
// still say the same thing, which is what this pins.
const admissionMessage = gestureRefusalMessage(IOS_TEST_DEVICE, 'multi-touch', 'pan');
const admissionMessage = gestureRefusalMessage(MACOS_TEST_DEVICE, 'multi-touch', 'pan');

await assert.rejects(
() => performGestureApple(IOS_TEST_DEVICE, {}, {}, twoFingerPanPlan()),
() => performGestureApple(MACOS_TEST_DEVICE, {}, {}, twoFingerPanPlan()),
(error: unknown) =>
error instanceof AppError &&
error.code === 'UNSUPPORTED_OPERATION' &&
error.message === admissionMessage &&
error.details?.hint === PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT,
error.details?.hint === APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS.macos,
);
assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0);
});
Expand Down
15 changes: 3 additions & 12 deletions packages/platform-apple/src/multitouch-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types';

export const APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS: Partial<Record<AppleOS, string>> = {
visionos: 'visionOS uses spatial input and does not support two-finger touch synthesis.',
tvos: 'tvOS has no touch input — this gesture is supported on Android and the iOS simulator only.',
tvos: 'tvOS has no touch input — this gesture is supported on Android, iOS, and iPadOS.',
macos:
'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.',
'macOS automation has no multi-touch input — this gesture is supported on Android, iOS, and iPadOS.',
};

/** One policy shared by command admission and the defensive Apple adapter check. */
Expand All @@ -24,16 +24,7 @@ export function assertAppleMultiTouchSupported(
throw unsupported(intent, device.platform);
}
const appleOs = resolveDeviceAppleOs(device);
if ((appleOs === 'ios' || appleOs === 'ipados') && device.kind === 'simulator') return;
if (appleOs === 'ios' || appleOs === 'ipados') {
throw new AppError(
'UNSUPPORTED_OPERATION',
`gesture ${intent} is not supported on physical iOS devices`,
{
hint: 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.',
},
);
}
if (appleOs === 'ios' || appleOs === 'ipados') return;
throw unsupported(
intent,
APPLE_OS_DISPLAY_NAMES[appleOs],
Expand Down
10 changes: 6 additions & 4 deletions packages/provider-webdriver/src/platform-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,8 +513,8 @@ function host(run: PlatformRuntimeHost['commands']['run']): PlatformRuntimeHost
}

// R52/R53: gestures and scrolling ride the same reachability gate the captures do. The one extra
// gate is the retired multi-touch policy — this provider owns physical devices only, and
// two-finger synthesis on a physical iOS device was refused before this migration too.
// gate is multi-touch: this provider reaches iOS through its own WebDriver interactor, which has no
// two-finger synthesis.
test.each([
['Android physical', device, true],
['iOS physical', { ...device, platform: 'apple' as const, appleOs: 'ios' as const }, false],
Expand All @@ -534,8 +534,10 @@ test.each([
expect(facts.operations.scrollDirection).toEqual({ available: true });
expect(facts.operations.performMultiTouchGesturePlan.available).toBe(multiTouch);
if (!multiTouch) {
expect(facts.operations.performMultiTouchGesturePlan).toMatchObject({
hint: 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.',
expect(facts.operations.performMultiTouchGesturePlan).toEqual({
available: false,
reason: 'unsupported-provider-mode',
hint: 'Two-finger gesture synthesis is not available on iOS devices reached through a WebDriver provider.',
});
}
});
Expand Down
16 changes: 7 additions & 9 deletions packages/provider-webdriver/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
bindProviderFocusInteractor,
focusRuntimeOperationFacts,
} from '@agent-device/contracts/focus-runtime';
import { PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT } from '@agent-device/contracts/gesture-admission';
import {
bindProviderGestureInteractor,
gestureRuntimeOperationFacts,
Expand Down Expand Up @@ -147,9 +146,9 @@ const scrollUnavailable = unavailableFact(
'unsupported-provider-mode',
'This WebDriver provider runtime does not expose scrolling for this device.',
);
const physicalIosMultiTouchUnavailable = unavailableFact(
'unsupported-device-kind',
PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT,
const iosMultiTouchUnavailable = unavailableFact(
'unsupported-provider-mode',
'Two-finger gesture synthesis is not available on iOS devices reached through a WebDriver provider.',
);
const elementTextUnavailable = unavailableFact(
'unsupported-provider-mode',
Expand Down Expand Up @@ -574,9 +573,8 @@ function webDriverFacts(
fillRef: typeUnavailable,
}),
// Gestures and scrolling ride the same provider interactor the captures do, so they need the
// same reachability. The one extra gate is the retired multi-touch policy: this provider only
// ever owns physical devices, and two-finger synthesis on a physical iOS device was refused
// before this migration exactly as it is refused here.
// same reachability. The one extra gate is multi-touch: this provider reaches iOS only through
// its own WebDriver interactor, which has no two-finger synthesis, not the Apple runner.
...gestureRuntimeOperationFacts({
unsupported: gestureUnavailable,
plan: interactorCell(reachable, gestureUnavailable),
Expand Down Expand Up @@ -662,10 +660,10 @@ function webDriverFacts(
});
}

/** Two-finger synthesis is iOS-simulator only, and this provider owns no simulators. */
/** Two-finger synthesis is an Apple runner capability, and this provider never uses the runner. */
function webDriverMultiTouchCell(device: DeviceInfo, reachable: boolean): RuntimeOperationFact {
if (!reachable) return gestureUnavailable;
return device.platform === 'apple' ? physicalIosMultiTouchUnavailable : available;
return device.platform === 'apple' ? iosMultiTouchUnavailable : available;
}

/** The device shapes this provider can reach at all through its own WebDriver interactor. */
Expand Down
2 changes: 1 addition & 1 deletion src/commands/schema/cli-help-first-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const FIRST_SCREEN_RULES: Record<string, readonly string[]> = {
],
gestures: [
'longpress accepts coordinates, @refs, or selectors; prefer @ref/selector, coordinates only as a fallback',
'iOS simulator transform/pinch/rotate use private XCTest synthesis for a continuous two-finger pan/scale/rotation path; verify app metrics instead of assuming requested values map exactly to recognizer output',
'iOS transform/pinch/rotate use private XCTest synthesis for a continuous two-finger pan/scale/rotation path; verify app metrics instead of assuming requested values map exactly to recognizer output',
'Rare iOS accessibility gap: a row shown disabled/hittable:false where press reports success but no UI change, or a collapsed composite control with no child refs -- run snapshot -i --json, compute the target center from rects, press x y, then diff snapshot -i. Coordinates are fallback-only; document why you used them.',
],
tv: [
Expand Down
Loading