diff --git a/packages/contracts/src/gesture-runtime.test.ts b/packages/contracts/src/gesture-runtime.test.ts index edec2d87e2..e2f9affea0 100644 --- a/packages/contracts/src/gesture-runtime.test.ts +++ b/packages/contracts/src/gesture-runtime.test.ts @@ -19,6 +19,7 @@ const available = { available: true } as const; const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const; const allAvailable = gestureRuntimeOperationFacts({ + unsupported: unavailable, plan: available, directionalFling: available, multiTouch: available, @@ -43,9 +44,10 @@ const plan: GesturePlan = { ], }; -test('builds the exact gesture operation fact catalog', () => { +test('builds the exact gesture operation fact catalog for an owner that names every tier', () => { expect( gestureRuntimeOperationFacts({ + unsupported: unavailable, plan: available, directionalFling: unavailable, multiTouch: unavailable, @@ -61,6 +63,37 @@ test('builds the exact gesture operation fact catalog', () => { }); }); +test('a tier the owner never names reports the denial the owner stated for the family, verbatim — omission is a classified refusal, never an unclassified tier and never an implied success', () => { + const denial = { + available: false, + reason: 'unsupported-device-kind', + hint: 'Gestures are supported on HarmonyOS emulators and physical devices.', + } as const; + + expect(gestureRuntimeOperationFacts({ unsupported: denial, plan: available })).toEqual({ + performGesturePlan: available, + performDirectionalFlingPlan: denial, + performMultiTouchGesturePlan: denial, + performTargetAuthoredDrag: denial, + gestureViewport: denial, + }); +}); + +test('an owner serving no gesture tier names the family denial once and still answers with the exhaustive shape', () => { + const denial = { available: false, reason: 'unsupported-platform-leaf' } as const; + + const facts = gestureRuntimeOperationFacts({ unsupported: denial }); + + expect(facts).toEqual({ + performGesturePlan: denial, + performDirectionalFlingPlan: denial, + performMultiTouchGesturePlan: denial, + performTargetAuthoredDrag: denial, + gestureViewport: denial, + }); + expect(Object.isFrozen(facts)).toBe(true); +}); + 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); @@ -110,11 +143,9 @@ test('a binding exposes only the tiers its owner facts admitted', () => { device, signal: new AbortController().signal, facts: gestureRuntimeOperationFacts({ + unsupported: unavailable, plan: available, - directionalFling: unavailable, - multiTouch: unavailable, targetAuthoredDrag: unavailable, - viewport: unavailable, }), resolveInteractor: async () => ({ performGesture: async () => ({}) }) as unknown as Interactor, }); diff --git a/packages/contracts/src/gesture-runtime.ts b/packages/contracts/src/gesture-runtime.ts index eded5e3225..589cf9b0f9 100644 --- a/packages/contracts/src/gesture-runtime.ts +++ b/packages/contracts/src/gesture-runtime.ts @@ -8,7 +8,7 @@ import { type ProviderInteractorOperationResolver, } from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts'; import { invalidRuntimeContract } from './runtime-contract-error.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -70,22 +70,38 @@ export type GestureRuntimeOperationFacts = Readonly<{ gestureViewport: RuntimeOperationFact; }>; +/** + * What an owner declares about the gesture family. Every tier is a per-owner claim — no tier is + * one every owner serves, and several owners serve none — so every tier is optional and + * `unsupported` names the denial an omitted tier reports. The tiers split exactly where an owner's + * mechanics split, so an owner states a tier only to say something the family denial does not. + * + * Omission is a classified denial, never an unclassified tier and never an implied success: the + * type refuses a call that does not carry `unsupported`, so no owner can leave the family blank. + * `unsupported` must refuse the family rather than one tier of it, because whatever the owner + * leaves unnamed reports that cell verbatim. + */ +export type GestureRuntimeOperationFactsInput = Readonly<{ + unsupported: RuntimeOperationUnavailability; + plan?: RuntimeOperationFact; + directionalFling?: RuntimeOperationFact; + multiTouch?: RuntimeOperationFact; + targetAuthoredDrag?: RuntimeOperationFact; + viewport?: RuntimeOperationFact; +}>; + /** Builds the exhaustive owner claims for the five gesture requirements. */ export function gestureRuntimeOperationFacts( - input: Readonly<{ - plan: RuntimeOperationFact; - directionalFling: RuntimeOperationFact; - multiTouch: RuntimeOperationFact; - targetAuthoredDrag: RuntimeOperationFact; - viewport: RuntimeOperationFact; - }>, + input: GestureRuntimeOperationFactsInput, ): GestureRuntimeOperationFacts { + const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact => + fact ?? input.unsupported; return Object.freeze({ - performGesturePlan: input.plan, - performDirectionalFlingPlan: input.directionalFling, - performMultiTouchGesturePlan: input.multiTouch, - performTargetAuthoredDrag: input.targetAuthoredDrag, - gestureViewport: input.viewport, + performGesturePlan: declared(input.plan), + performDirectionalFlingPlan: declared(input.directionalFling), + performMultiTouchGesturePlan: declared(input.multiTouch), + performTargetAuthoredDrag: declared(input.targetAuthoredDrag), + gestureViewport: declared(input.viewport), }); } diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index b4fca03902..9735b0575e 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -224,13 +224,7 @@ export function createUnavailablePlatformRuntimeFacts( }), ...viewportRuntimeOperationFacts({ setViewport: frozen.viewport }), ...focusRuntimeOperationFacts({ focus: frozen.focus }), - ...gestureRuntimeOperationFacts({ - plan: frozen.gesture, - directionalFling: frozen.gesture, - multiTouch: frozen.gesture, - targetAuthoredDrag: frozen.gesture, - viewport: frozen.gesture, - }), + ...gestureRuntimeOperationFacts({ unsupported: frozen.gesture }), ...scrollRuntimeOperationFacts({ scroll: frozen.scroll }), ...typeTextRuntimeOperationFacts({ type: frozen.typeText }), ...touchRuntimeOperationFacts({ diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index ae4dfa12d5..0e0ed64cba 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -319,6 +319,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ...focusRuntimeOperationFacts({ focus: androidTouchFact(device) }), ...gestureRuntimeOperationFacts({ + unsupported: gestureKindUnavailable, plan: androidGestureFact(device), directionalFling: androidGestureFact(device), multiTouch: androidTouchTargetFact(device, androidTvMultiTouchUnavailable), diff --git a/packages/platform-apple/src/gesture-facts.ts b/packages/platform-apple/src/gesture-facts.ts index c740c81f1e..93e651f196 100644 --- a/packages/platform-apple/src/gesture-facts.ts +++ b/packages/platform-apple/src/gesture-facts.ts @@ -4,7 +4,10 @@ import { TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT, } from '@agent-device/contracts/gesture-admission'; import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; -import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import type { + RuntimeOperationFact, + RuntimeOperationUnavailability, +} from '@agent-device/contracts/platform-runtime'; import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; @@ -38,6 +41,7 @@ function unsupportedAppleDeviceKind(hint: string) { export function appleGestureAndScrollFacts(device: DeviceInfo) { return { ...gestureRuntimeOperationFacts({ + unsupported: appleGestureFamilyUnavailable(device), plan: appleGesturePlanFact(device), directionalFling: appleGesturePlanFact(device), multiTouch: appleMultiTouchGestureFact(device), @@ -48,6 +52,13 @@ export function appleGestureAndScrollFacts(device: DeviceInfo) { }; } +/** The reason this leaf refuses gestures it does not name, before any tier is consulted. */ +function appleGestureFamilyUnavailable(device: DeviceInfo): RuntimeOperationUnavailability { + return device.appleOs === 'watchos' || device.appleOs === 'visionos' + ? gestureLeafUnavailable + : gestureKindUnavailable; +} + function appleGesturePlanFact(device: DeviceInfo): RuntimeOperationFact { if (device.appleOs === 'watchos' || device.appleOs === 'visionos') return gestureLeafUnavailable; return appleTouchKind(device) ? available : gestureKindUnavailable; diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 9424eee648..5e2412f51b 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -252,6 +252,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor // Gestures share focus's HDC-driven kind cell; only the two tiers HDC cannot synthesize // are refused. ...gestureRuntimeOperationFacts({ + unsupported: gestureKindUnavailable, plan: harmonyGestureFact(device), directionalFling: harmonyGestureFact(device), multiTouch: multiTouchUnavailable, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index ab5c30efc3..91f668cc6f 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -247,6 +247,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // every cell — a direction-authored fling's speed semantics, two-contact synthesis, and // target-authored drag timing. ...gestureRuntimeOperationFacts({ + unsupported: gestureKindUnavailable, plan: linuxDesktopFact(device, gestureKindUnavailable), directionalFling: directionalFlingUnavailable, multiTouch: multiTouchUnavailable, diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 100fa1ea85..279f51f8c3 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -232,11 +232,9 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts // one; the rest had no retired closure and now refuse at admission rather than inside the // Vega interactor. ...gestureRuntimeOperationFacts({ - plan: gestureUnavailable, - directionalFling: gestureUnavailable, + unsupported: gestureUnavailable, multiTouch: multiTouchUnavailable, targetAuthoredDrag: targetAuthoredDragUnavailable, - viewport: gestureUnavailable, }), }, }); diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 1ed2ee4131..0c6b8207da 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -399,11 +399,8 @@ function webRuntimeFacts( // outright. Drag is the exception it checked FIRST, by naming the phases an adapter needs. ...scrollRuntimeOperationFacts({ scroll: browserDevice }), ...gestureRuntimeOperationFacts({ - plan: gestureUnavailable, - directionalFling: gestureUnavailable, - multiTouch: gestureUnavailable, + unsupported: gestureUnavailable, targetAuthoredDrag: targetAuthoredDragUnavailable, - viewport: gestureUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: browserDevice }), // The web backend has no point-addressed read: `get` answers from the captured DOM tree, diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 198745d405..5b450e85d3 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -481,11 +481,16 @@ test('closes every Limrun gesture and scroll cell without a live session', async const facts = await owner.inspectFacts(limrunAndroid); for (const operation of [ 'performGesturePlan', + 'performDirectionalFlingPlan', 'performMultiTouchGesturePlan', 'performTargetAuthoredDrag', 'gestureViewport', 'scrollDirection', ] as const) { - expect(facts.operations[operation].available).toBe(false); + expect(facts.operations[operation]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + hint: 'Limrun requires a matching live provider session for this device.', + }); } }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index e46cdfd472..80343e8811 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -54,6 +54,11 @@ const iosGestureUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose portable gesture execution yet.', } as const); +/** What an unnamed gesture tier reports on a live Limrun session. */ +const gestureUnsupportedProviderMode = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', +} as const); const androidTvMultiTouchUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -74,26 +79,15 @@ function limrunGestureFacts( device: DeviceInfo, cell: RuntimeOperationUnavailability | typeof available, ): GestureRuntimeOperationFacts { - if (cell !== available) { - return gestureRuntimeOperationFacts({ - plan: cell, - directionalFling: cell, - multiTouch: cell, - targetAuthoredDrag: cell, - viewport: cell, - }); + if (!cell.available) { + return gestureRuntimeOperationFacts({ unsupported: cell }); } if (device.platform !== 'android') { - return gestureRuntimeOperationFacts({ - plan: iosGestureUnavailable, - directionalFling: iosGestureUnavailable, - multiTouch: iosGestureUnavailable, - targetAuthoredDrag: iosGestureUnavailable, - viewport: iosGestureUnavailable, - }); + return gestureRuntimeOperationFacts({ unsupported: iosGestureUnavailable }); } const tv = device.target === 'tv'; return gestureRuntimeOperationFacts({ + unsupported: gestureUnsupportedProviderMode, plan: available, directionalFling: available, multiTouch: tv ? androidTvMultiTouchUnavailable : available, diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 45c6068da4..2e82d2952a 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -628,6 +628,7 @@ function webDriverFacts( // ever owns physical devices, and two-finger synthesis on a physical iOS device was refused // before this migration exactly as it is refused here. ...gestureRuntimeOperationFacts({ + unsupported: gestureUnavailable, plan: interactorCell(reachable, gestureUnavailable), directionalFling: interactorCell(reachable, gestureUnavailable), multiTouch: webDriverMultiTouchCell(device, reachable), diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 80999ed3f3..fb3b415be4 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -52,13 +52,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre longPress: unavailable, fill: unavailable, }), - ...gestureRuntimeOperationFacts({ - plan: unavailable, - directionalFling: unavailable, - multiTouch: unavailable, - targetAuthoredDrag: unavailable, - viewport: unavailable, - }), + ...gestureRuntimeOperationFacts({ unsupported: unavailable }), ...scrollRuntimeOperationFacts({ scroll: unavailable }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: unavailable }), back: unavailable, diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 31da7ffa42..338364d7dd 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -369,13 +369,7 @@ function sourceRuntimeFacts( longPress: unavailable, fill: unavailable, }), - ...gestureRuntimeOperationFacts({ - plan: unavailable, - directionalFling: unavailable, - multiTouch: unavailable, - targetAuthoredDrag: unavailable, - viewport: unavailable, - }), + ...gestureRuntimeOperationFacts({ unsupported: unavailable }), ...scrollRuntimeOperationFacts({ scroll: unavailable }), readTextAtPoint: unavailable, back: unavailable,