Skip to content
Open
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
12 changes: 2 additions & 10 deletions packages/contracts/src/apple-multitouch-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,11 @@ import {
type AppleOS,
type DeviceInfo,
} from '@agent-device/kernel/device';
import { APPLE_OS_DISPLAY_NAMES } from './apple-os-display-names.ts';
import { AppError } from '@agent-device/kernel/errors';
import type { GesturePlan } from './gesture-plan-types.ts';

const APPLE_OS_DISPLAY_NAMES: Record<AppleOS, string> = {
ios: 'iOS',
ipados: 'iPadOS',
tvos: 'tvOS',
watchos: 'watchOS',
visionos: 'visionOS',
macos: 'macOS',
};

const APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS: Partial<Record<AppleOS, string>> = {
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.',
macos:
Expand Down
18 changes: 18 additions & 0 deletions packages/contracts/src/apple-os-display-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { AppleOS } from '@agent-device/kernel/device';

/**
* How each Apple OS names itself in agent-facing prose.
*
* Its own module because two callers need it — the defensive adapter check in
* `apple-multitouch-support.ts` and the gesture refusal subject in `gesture-admission.ts` — and
* neither the display table nor the wording it produces is a public contracts surface. Keeping it
* out of a façade-re-exported module is what lets both callers share ONE copy of the wording.
*/
export const APPLE_OS_DISPLAY_NAMES: Record<AppleOS, string> = {
ios: 'iOS',
ipados: 'iPadOS',
tvos: 'tvOS',
watchos: 'watchOS',
visionos: 'visionOS',
macos: 'macOS',
};
39 changes: 39 additions & 0 deletions packages/contracts/src/facades/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export {
providerRuntimeOwner,
runtimeOwnerKey,
sameRuntimeOwner,
whenAdmitted,
} from '../platform-runtime.ts';
export type {
BoundDeviceRuntime,
Expand Down Expand Up @@ -223,11 +224,17 @@ export {
waitSelectorCaptureRuntimePlanUses,
findRuntimePlanUses,
focusRuntimeUse,
gestureRuntimePlanUses,
resolveGestureRuntimePlan,
resolveScrollRuntimePlan,
scrollRuntimePlanUses,
typeTextRuntimeUse,
viewportRuntimeUse,
} from '../platform-runtime-operations.ts';
export type {
GestureRuntimePlan,
ScreenshotRuntimePlan,
ScrollRuntimePlan,
SelectorCaptureRuntimeIntent,
SelectorCaptureRuntimePlan,
SnapshotRuntimePlan,
Expand Down Expand Up @@ -315,6 +322,38 @@ export type {
TypeTextRuntimeOperationFacts,
TypeTextRuntimeOperations,
} from '../type-text-runtime.ts';
export {
bindLocalGestureInteractor,
bindProviderGestureInteractor,
gestureRuntimeOperationFacts,
} from '../gesture-runtime.ts';
export type {
GesturePlanInput,
GestureRuntimeOperationFacts,
GestureRuntimeOperations,
GestureViewportInput,
LocalGestureInteractorResolver,
ProviderGestureInteractorResolver,
} from '../gesture-runtime.ts';
export {
ANDROID_TV_MULTI_TOUCH_UNSUPPORTED_HINT,
gestureRefusalMessage,
PHYSICAL_IOS_MULTI_TOUCH_UNSUPPORTED_HINT,
TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT,
} from '../gesture-admission.ts';
export { APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS } from '../apple-multitouch-support.ts';
export {
bindLocalScrollInteractor,
bindProviderScrollInteractor,
scrollRuntimeOperationFacts,
} from '../scroll-runtime.ts';
export type {
LocalScrollInteractorResolver,
ProviderScrollInteractorResolver,
ScrollDirectionInput,
ScrollRuntimeOperationFacts,
ScrollRuntimeOperations,
} from '../scroll-runtime.ts';
export { viewportRuntimeOperationFacts } from '../viewport-runtime.ts';
export type {
SetViewportInput,
Expand Down
65 changes: 65 additions & 0 deletions packages/contracts/src/gesture-admission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
isApplePlatform,
resolveDeviceAppleOs,
type DeviceInfo,
} from '@agent-device/kernel/device';
import { APPLE_OS_DISPLAY_NAMES } from './apple-os-display-names.ts';
import type { GestureCommandInput } from './gesture-plan-types.ts';
import type { GestureRuntimeTier } from './gesture-tier.ts';

/** The hint an owner states when it cannot preserve a target-authored drag's timing. */
export const TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT =
'Target-authored drag requires an adapter that preserves source hold, timed movement, and destination hold; it is supported on Android touch devices and iOS/iPadOS.';

/** 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.';

/**
* 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
* 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.
*/
function gestureRefusalSubject(device: DeviceInfo, tier: GestureRuntimeTier): string {
const owned =
tier === 'multi-touch'
? multiTouchRefusalSubject(device)
: tier === 'directional-fling' && device.platform === 'linux'
? 'Linux'
: undefined;
return owned ?? device.appleOs ?? device.platform;
}

/**
* The three owner-specific subjects two-contact synthesis produced, or `undefined` where the
* retired admission fell through to the plain platform name.
*/
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') {
return APPLE_OS_DISPLAY_NAMES[appleOs];
}
return undefined;
}

/**
* The refusal one unavailable gesture cell reports. `gesture fling` on Linux keeps its bare intent
* wording because its subject is the platform's display name, so no special-casing is needed here.
*/
export function gestureRefusalMessage(
device: DeviceInfo,
tier: GestureRuntimeTier,
intent: GestureCommandInput['intent'],
): string {
return `gesture ${intent} is not supported on ${gestureRefusalSubject(device, tier)}`;
}
215 changes: 215 additions & 0 deletions packages/contracts/src/gesture-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { expect, test, vi } from 'vitest';
import {
bindLocalGestureInteractor,
bindProviderGestureInteractor,
gestureRuntimeOperationFacts,
} from './gesture-runtime.ts';
import type { GesturePlan } from './gesture-plan-types.ts';
import type { Interactor } from './interactor-types.ts';

const device = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
} as const;

const available = { available: true } as const;
const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const;

const allAvailable = gestureRuntimeOperationFacts({
plan: available,
directionalFling: available,
multiTouch: available,
targetAuthoredDrag: available,
viewport: available,
});

const plan: GesturePlan = {
topology: 'single',
intent: 'pan',
executionProfile: 'timed-pan',
durationMs: 300,
viewport: { x: 0, y: 0, width: 400, height: 800 },
pointers: [
{
pointerId: 0,
samples: [
{ offsetMs: 0, point: { x: 10, y: 20 } },
{ offsetMs: 300, point: { x: 10, y: 220 } },
],
},
],
};

test('builds the exact gesture operation fact catalog', () => {
expect(
gestureRuntimeOperationFacts({
plan: available,
directionalFling: unavailable,
multiTouch: unavailable,
targetAuthoredDrag: available,
viewport: unavailable,
}),
).toEqual({
performGesturePlan: available,
performDirectionalFlingPlan: unavailable,
performMultiTouchGesturePlan: unavailable,
performTargetAuthoredDrag: available,
gestureViewport: unavailable,
});
});

test('a local binding executes the plan through the owner interactor', async () => {
const performGesture = vi.fn(async () => ({ backend: 'adb' }));
const resolveInteractor = vi.fn(async () => ({ performGesture }) as unknown as Interactor);
const signal = new AbortController().signal;

const operations = bindLocalGestureInteractor({
device,
signal,
facts: allAvailable,
resolveInteractor,
});
await operations.performGesturePlan?.({
plan,
options: { appBundleId: 'com.example.app' },
execution: { logPath: '/tmp/daemon.log', requestId: 'gesture-1' },
});

expect(resolveInteractor).toHaveBeenCalledWith(device, {
logPath: '/tmp/daemon.log',
requestId: 'gesture-1',
appBundleId: 'com.example.app',
signal,
});
// The plan reaches the seam whole and unmodified — this is the sole argument, so an executor
// that dropped or rebuilt it shows up here.
expect(performGesture).toHaveBeenCalledWith(plan);
});

test('every admitted tier reaches the same single plan executor', async () => {
const performGesture = vi.fn(async () => ({}));
const operations = bindLocalGestureInteractor({
device,
signal: new AbortController().signal,
facts: allAvailable,
resolveInteractor: async () => ({ performGesture }) as unknown as Interactor,
});

await operations.performDirectionalFlingPlan?.({ plan });
await operations.performMultiTouchGesturePlan?.({ plan });
await operations.performTargetAuthoredDrag?.({ plan });

expect(performGesture).toHaveBeenCalledTimes(3);
});

test('a binding exposes only the tiers its owner facts admitted', () => {
const operations = bindLocalGestureInteractor({
device,
signal: new AbortController().signal,
facts: gestureRuntimeOperationFacts({
plan: available,
directionalFling: unavailable,
multiTouch: unavailable,
targetAuthoredDrag: unavailable,
viewport: unavailable,
}),
resolveInteractor: async () => ({ performGesture: async () => ({}) }) as unknown as Interactor,
});

expect(operations.performGesturePlan).toBeTypeOf('function');
expect(operations.performDirectionalFlingPlan).toBeUndefined();
expect(operations.performMultiTouchGesturePlan).toBeUndefined();
expect(operations.performTargetAuthoredDrag).toBeUndefined();
expect(operations.gestureViewport).toBeUndefined();
});

test('a local binding reads the owner frame through its interactor', async () => {
const gestureViewport = vi.fn(async () => ({ x: 0, y: 0, width: 393, height: 852 }));
const resolveInteractor = vi.fn(async () => ({ gestureViewport }) as unknown as Interactor);
const signal = new AbortController().signal;

const operations = bindLocalGestureInteractor({
device,
signal,
facts: allAvailable,
resolveInteractor,
});

await expect(
operations.gestureViewport?.({ execution: { requestId: 'gesture-2' } }),
).resolves.toEqual({ x: 0, y: 0, width: 393, height: 852 });
expect(resolveInteractor).toHaveBeenCalledWith(device, {
requestId: 'gesture-2',
appBundleId: undefined,
signal,
});
});

test('a provider binding executes through its own resolved interactor', async () => {
const performGesture = vi.fn(async () => ({}));
const resolveInteractor = vi.fn(() => ({ performGesture }) as unknown as Interactor);
const signal = new AbortController().signal;

const operations = bindProviderGestureInteractor({
device,
signal,
facts: allAvailable,
resolveInteractor,
});
await operations.performGesturePlan?.({ plan, execution: { requestId: 'gesture-3' } });

expect(resolveInteractor).toHaveBeenCalledWith({
requestId: 'gesture-3',
appBundleId: undefined,
signal,
});
expect(performGesture).toHaveBeenCalledWith(plan);
});

test('a provider binding fails closed when its exact owner exposes no interactor', async () => {
const operations = bindProviderGestureInteractor({
device,
signal: new AbortController().signal,
facts: allAvailable,
resolveInteractor: () => undefined,
});

await expect(operations.performGesturePlan?.({ plan })).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id },
});
});

test('an advertised tier whose interactor cannot execute is a contract bug, not a refusal', async () => {
const operations = bindLocalGestureInteractor({
device,
signal: new AbortController().signal,
facts: allAvailable,
resolveInteractor: async () => ({}) as unknown as Interactor,
});

await expect(operations.performGesturePlan?.({ plan })).rejects.toMatchObject({
message: expect.stringContaining('advertised gesture execution'),
});
});

test('an already-cancelled request never resolves an interactor', async () => {
const controller = new AbortController();
controller.abort();
const performGesture = vi.fn(async () => ({}));
const resolveInteractor = vi.fn(async () => ({ performGesture }) as unknown as Interactor);

const operations = bindLocalGestureInteractor({
device,
signal: controller.signal,
facts: allAvailable,
resolveInteractor,
});

await expect(operations.performGesturePlan?.({ plan })).rejects.toThrow();
expect(resolveInteractor).not.toHaveBeenCalled();
expect(performGesture).not.toHaveBeenCalled();
});
Loading
Loading