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
6 changes: 1 addition & 5 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,10 @@ export function createUnavailablePlatformRuntimeFacts(
...scrollRuntimeOperationFacts({ scroll: frozen.scroll }),
...typeTextRuntimeOperationFacts({ type: frozen.typeText }),
...touchRuntimeOperationFacts({
unsupported: frozen.touch,
tap: frozen.touch,
tapRef: frozen.touch,
longPress: frozen.touch,
hover: frozen.touch,
hoverRef: frozen.touch,
fill: frozen.touch,
fillRef: frozen.touch,
tapElementSelector: frozen.touch,
}),
...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }),
...backRuntimeOperationFacts({ back: frozen.back }),
Expand Down
30 changes: 27 additions & 3 deletions packages/contracts/src/touch-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ const device = {
const available = { available: true } as const;
const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const;
const facts = touchRuntimeOperationFacts({
unsupported: unavailable,
tap: available,
tapRef: available,
longPress: available,
hover: unavailable,
hoverRef: unavailable,
fill: available,
fillRef: available,
tapElementSelector: unavailable,
});

test('builds exact touch facts and carries the hover refusal hint', () => {
Expand All @@ -39,6 +37,32 @@ test('builds exact touch facts and carries the hover refusal hint', () => {
});
});

test("an operation the owner never names reports the owner's own denial verbatim", () => {
const denial = {
available: false,
reason: 'unsupported-device-kind',
hint: 'focus is supported on Android emulators and physical devices.',
} as const;

expect(
touchRuntimeOperationFacts({
unsupported: denial,
tap: available,
longPress: available,
fill: available,
}),
).toEqual({
tapPoint: available,
tapRef: denial,
longPressPoint: available,
hoverPoint: denial,
hoverRef: denial,
fillPoint: available,
fillRef: denial,
tapElementSelector: denial,
});
});

test('the owner binds a ref operation only when its exact fact admits it', async () => {
const tapRef = vi.fn(async () => ({ route: 'ref' }));
const resolveInteractor = vi.fn(async () => ({ tapRef }) as unknown as Interactor);
Expand Down
66 changes: 38 additions & 28 deletions packages/contracts/src/touch-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
PressPointOptions,
RunnerContext,
} from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts';
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';

type TouchExecutionInput = Readonly<{
Expand Down Expand Up @@ -74,42 +74,52 @@ export type TouchRuntimeOperationFacts = Readonly<{
export const HOVER_UNAVAILABLE_HINT =
'hover raises pointer hover state and is available on web targets only. On touch platforms use longpress for hold gestures.';

/**
* What an owner declares about the touch family. `tap`, `longPress` and `fill` are universally
* implemented, so they stay required cells — a compile-time guard against an owner silently
* dropping one. Every other operation is optional, and `unsupported` names the denial an omitted
* cell reports: an owner that does not implement one of those already classified it, by the same
* `unsupported` cell, with the same reason and hint it would have written out by hand. Adding a new
* touch operation therefore costs an edit only in the owner that gained it.
*
* Omission is a classified denial, never an unclassified cell and never an implied success: the
* type refuses a call that does not carry `unsupported`, so no owner can leave the family blank.
*/
export type TouchRuntimeOperationFactsInput = Readonly<{
unsupported: RuntimeOperationUnavailability;
tap: RuntimeOperationFact;
tapRef?: RuntimeOperationFact;
longPress: RuntimeOperationFact;
hover?: RuntimeOperationFact;
hoverRef?: RuntimeOperationFact;
fill: RuntimeOperationFact;
fillRef?: RuntimeOperationFact;
tapElementSelector?: RuntimeOperationFact;
}>;

export function touchRuntimeOperationFacts(
input: Readonly<{
tap: RuntimeOperationFact;
tapRef?: RuntimeOperationFact;
longPress: RuntimeOperationFact;
hover: RuntimeOperationFact;
hoverRef?: RuntimeOperationFact;
fill: RuntimeOperationFact;
fillRef?: RuntimeOperationFact;
tapElementSelector: RuntimeOperationFact;
}>,
input: TouchRuntimeOperationFactsInput,
): TouchRuntimeOperationFacts {
const hoverRef: RuntimeOperationFact = input.hoverRef ?? NATIVE_REF_UNAVAILABLE;
const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact =>
fact ?? input.unsupported;
return Object.freeze({
tapPoint: input.tap,
tapRef: input.tapRef ?? NATIVE_REF_UNAVAILABLE,
tapRef: declared(input.tapRef),
longPressPoint: input.longPress,
hoverPoint: input.hover.available
? input.hover
: Object.freeze({ ...input.hover, hint: input.hover.hint ?? HOVER_UNAVAILABLE_HINT }),
hoverRef: hoverRef.available
? hoverRef
: Object.freeze({
...hoverRef,
hint: hoverRef.hint ?? HOVER_UNAVAILABLE_HINT,
}),
hoverPoint: withHoverRefusalHint(declared(input.hover)),
hoverRef: withHoverRefusalHint(declared(input.hoverRef)),
fillPoint: input.fill,
fillRef: input.fillRef ?? NATIVE_REF_UNAVAILABLE,
tapElementSelector: input.tapElementSelector,
fillRef: declared(input.fillRef),
tapElementSelector: declared(input.tapElementSelector),
});
}

const NATIVE_REF_UNAVAILABLE = Object.freeze({
available: false,
reason: 'owner-capability-missing',
} as const);
/** Both hover legs answer with the same redirection to longpress when they are refused. */
function withHoverRefusalHint(fact: RuntimeOperationFact): RuntimeOperationFact {
return fact.available
? fact
: Object.freeze({ ...fact, hint: fact.hint ?? HOVER_UNAVAILABLE_HINT });
}

function runnerContext(input: TouchExecutionInput, signal: AbortSignal): RunnerContext {
return { ...input.execution, appBundleId: input.appBundleId, signal };
Expand Down
6 changes: 2 additions & 4 deletions packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,12 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
// row has no device behind it (parity with the retired `type` bucket).
...typeTextRuntimeOperationFacts({ type: androidTouchFact(device) }),
...touchRuntimeOperationFacts({
unsupported: focusKindUnavailable,
tap: androidTouchFact(device),
tapRef: focusKindUnavailable,
longPress: androidTouchFact(device),
// Hover is not a device-kind gap: no Android kind raises pointer hover state.
hover: hoverUnavailable,
hoverRef: focusKindUnavailable,
fill: androidTouchFact(device),
fillRef: focusKindUnavailable,
tapElementSelector: focusKindUnavailable,
}),
// uiautomator reads text at a point through the same adb path the snapshot uses, so the
// synthetic `simulator` row is the only Android kind without a live read.
Expand Down
24 changes: 24 additions & 0 deletions packages/platform-apple/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { listIosApps } from './core/app-resolution.ts';
import type { DeviceBinding, RuntimeFacts } from '@agent-device/contracts/platform-runtime';
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
import type { SnapshotRuntimeHost } from '@agent-device/contracts/snapshot-runtime';
import { HOVER_UNAVAILABLE_HINT } from '@agent-device/contracts/touch-runtime';
import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device';
import { createApplePlatformRuntime } from './runtime.ts';
import { platformRuntimeHostFixture } from './runtime.fixtures.ts';
Expand Down Expand Up @@ -220,6 +221,29 @@ test.each(Object.entries(leaves))(
},
);

test('hover has no Apple interactor route on macOS, iOS, or tvOS; the touch family reports its typed denial', async () => {
for (const device of [leaves.macos, leaves.ios, leaves.tvos]) {
const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({
device,
intent: { kind: 'ordinary' },
scope: {
signal: new AbortController().signal,
diagnostics: { emit: () => {} },
progress: { report: () => {} },
},
});
const hoverDenial = {
available: false,
reason: 'unsupported-platform-leaf',
hint: HOVER_UNAVAILABLE_HINT,
};
expect(binding.facts.operations.hoverPoint).toEqual(hoverDenial);
expect(binding.facts.operations.hoverRef).toEqual(hoverDenial);
expect(binding.operations.hoverPoint).toBeTypeOf('undefined');
expect(binding.operations.hoverRef).toBeTypeOf('undefined');
}
});

/** Both the fact and the bound operation function agree on availability, for one operation. */
function expectOperationAvailability(
binding: DeviceBinding<PlatformRuntimeOperations>,
Expand Down
7 changes: 2 additions & 5 deletions packages/platform-apple/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,14 +312,11 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
// exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`).
...typeTextRuntimeOperationFacts({ type: appleFocusFact(device) }),
...touchRuntimeOperationFacts({
unsupported: unavailable,
tap: appleFocusFact(device),
tapRef: unavailable,
longPress: appleFocusFact(device),
hover: unavailable,
hoverRef: unavailable,
fill: appleFocusFact(device),
fillRef: unavailable,
tapElementSelector: isIosFamily(device) ? appleFocusFact(device) : unavailable,
...(isIosFamily(device) ? { tapElementSelector: appleFocusFact(device) } : {}),
}),
...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }),
...appleNavigationFacts(device),
Expand Down
6 changes: 1 addition & 5 deletions packages/platform-harmonyos/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,14 +263,10 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor
// Text entry shares focus's cell: hdc drives both on the same two kinds.
...typeTextRuntimeOperationFacts({ type: harmonyFocusFact(device) }),
...touchRuntimeOperationFacts({
unsupported: unavailable,
tap: harmonyFocusFact(device),
tapRef: unavailable,
longPress: harmonyFocusFact(device),
hover: unavailable,
hoverRef: unavailable,
fill: harmonyFocusFact(device),
fillRef: unavailable,
tapElementSelector: unavailable,
}),
// HarmonyOS has no point-read tool: `get` answers from the captured tree, which is what
// the legacy dispatch already did after its Apple-runner attempt failed.
Expand Down
19 changes: 19 additions & 0 deletions packages/platform-linux/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
PlatformRuntimeOperations,
} from '@agent-device/contracts/platform-runtime-operations';
import type { SnapshotRuntimeHost } from '@agent-device/contracts/snapshot-runtime';
import { HOVER_UNAVAILABLE_HINT } from '@agent-device/contracts/touch-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createLinuxPlatformRuntime } from './runtime.ts';

Expand Down Expand Up @@ -306,6 +307,24 @@ test.each([
expect(facts.operations.gestureViewport.available).toBe(false);
});

test('hover has no Linux interactor route; the touch family reports its typed denial', async () => {
const facts = await createLinuxPlatformRuntime(lifecycleHost()).inspectFacts({
platform: 'linux',
id: 'linux',
name: 'Linux',
kind: 'device',
target: 'desktop',
booted: true,
});
const hoverDenial = {
available: false,
reason: 'unsupported-platform-leaf',
hint: HOVER_UNAVAILABLE_HINT,
};
expect(facts.operations.hoverPoint).toEqual(hoverDenial);
expect(facts.operations.hoverRef).toEqual(hoverDenial);
});

test('binds the Linux coordinate-fling tier without a frame read', async () => {
const binding = await createLinuxPlatformRuntime(lifecycleHost()).bind({
device: {
Expand Down
6 changes: 1 addition & 5 deletions packages/platform-linux/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,14 +288,10 @@ function linuxDesktopFact(
function linuxTouchFacts(device: DeviceInfo) {
const point = device.kind === 'device' ? supported : focusKindUnavailable;
return touchRuntimeOperationFacts({
unsupported: unsupportedPlatformLeaf,
tap: point,
tapRef: unsupportedPlatformLeaf,
longPress: point,
hover: unsupportedPlatformLeaf,
hoverRef: unsupportedPlatformLeaf,
fill: point,
fillRef: unsupportedPlatformLeaf,
tapElementSelector: unsupportedPlatformLeaf,
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/platform-web/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,14 +385,14 @@ function webRuntimeFacts(
// interactor to drive (parity with the retired `type` overlay membership).
...typeTextRuntimeOperationFacts({ type: browserDevice }),
...touchRuntimeOperationFacts({
unsupported: readinessUnavailable,
tap: browserDevice,
tapRef: webOptionalOperationFact(interactor?.tapRef, browserDevice),
longPress: readinessUnavailable,
hover: webOptionalOperationFact(interactor?.hover, browserDevice),
hoverRef: webOptionalOperationFact(interactor?.hoverRef, browserDevice),
fill: browserDevice,
fillRef: webOptionalOperationFact(interactor?.fillRef, browserDevice),
tapElementSelector: readinessUnavailable,
}),
// `scroll` is the one gesture-family command the web overlay admitted
// (`WEB_INTERACTION_COMMANDS`), so it shares focus's `{ device: true }` cell. `gesture` and
Expand Down
12 changes: 4 additions & 8 deletions packages/provider-limrun/src/interaction-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,17 +163,13 @@ export function limrunInteractionOperationFacts(
...focusRuntimeOperationFacts({ focus: cell }),
...typeTextRuntimeOperationFacts({ type: cell }),
...touchRuntimeOperationFacts({
unsupported: unsupportedTouch,
tap: cell,
tapRef: unsupportedTouch,
longPress: liveSessionUnavailable ?? (isIosFamily(device) ? unsupportedTouch : cell),
hover: liveSessionUnavailable ?? {
available: false,
reason: 'unsupported-provider-mode',
hint: 'hover raises pointer hover state and is available on web targets only. On touch platforms use longpress for hold gestures.',
},
hoverRef: unsupportedTouch,
// A dead session refuses hover for its own reason; the family builder adds the redirection
// to longpress either way.
hover: liveSessionUnavailable ?? unsupportedTouch,
fill: cell,
fillRef: unsupportedTouch,
tapElementSelector: liveSessionUnavailable ?? (isIosFamily(device) ? cell : unsupportedTouch),
}),
...limrunGestureFacts(device, cell),
Expand Down
6 changes: 2 additions & 4 deletions packages/provider-webdriver/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,14 +621,12 @@ function webDriverFacts(
...focusRuntimeOperationFacts({ focus: interactorCell(reachable, focusUnavailable) }),
...typeTextRuntimeOperationFacts({ type: declared('type', typeUnavailable) }),
...touchRuntimeOperationFacts({
unsupported: focusUnavailable,
tap: declared('tap', focusUnavailable),
tapRef: focusUnavailable,
longPress: declared('longPress', focusUnavailable),
hover: focusUnavailable,
hoverRef: focusUnavailable,
fill: declared('fill', typeUnavailable),
// Text entry, not focus, is what this provider lacks for the ref-addressed fill.
fillRef: typeUnavailable,
tapElementSelector: focusUnavailable,
}),
// 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
Expand Down
3 changes: 1 addition & 2 deletions src/__tests__/test-utils/runtime-operation-facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,10 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre
focusPoint: unavailable,
typeText: unavailable,
...touchRuntimeOperationFacts({
unsupported: unavailable,
tap: unavailable,
longPress: unavailable,
hover: unavailable,
fill: unavailable,
tapElementSelector: unavailable,
}),
...gestureRuntimeOperationFacts({
plan: unavailable,
Expand Down
3 changes: 1 addition & 2 deletions src/daemon/__tests__/screenshot-runtime-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,10 @@ export function screenshotRuntimeFixture(
withoutActiveApp: options.snapshot ?? available,
}),
...touchRuntimeOperationFacts({
unsupported: unavailable,
tap: available,
longPress: unavailable,
hover: unavailable,
fill: unavailable,
tapElementSelector: unavailable,
}),
},
};
Expand Down
3 changes: 1 addition & 2 deletions src/daemon/handlers/__tests__/install-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,11 +364,10 @@ function sourceRuntimeFacts(
focusPoint: unavailable,
typeText: unavailable,
...touchRuntimeOperationFacts({
unsupported: unavailable,
tap: unavailable,
longPress: unavailable,
hover: unavailable,
fill: unavailable,
tapElementSelector: unavailable,
}),
...gestureRuntimeOperationFacts({
plan: unavailable,
Expand Down
Loading
Loading