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
39 changes: 35 additions & 4 deletions packages/contracts/src/gesture-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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,
});
Expand Down
42 changes: 29 additions & 13 deletions packages/contracts/src/gesture-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
});
}

Expand Down
8 changes: 1 addition & 7 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 12 additions & 1 deletion packages/platform-apple/src/gesture-facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/platform-harmonyos/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/platform-linux/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
// 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,
Expand Down
4 changes: 1 addition & 3 deletions packages/platform-vega/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,9 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
// 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,
}),
},
});
Expand Down
5 changes: 1 addition & 4 deletions packages/platform-web/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/provider-limrun/src/app-log-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
});
}
});
24 changes: 9 additions & 15 deletions packages/provider-limrun/src/interaction-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/provider-webdriver/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 1 addition & 7 deletions src/__tests__/test-utils/runtime-operation-facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 1 addition & 7 deletions src/daemon/handlers/__tests__/install-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading