From c899cb1bef6f57fe027de72f3bfc3e5871a2eda2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 11:10:21 +0200 Subject: [PATCH 1/4] refactor(contracts): make touch capability facts additive The touch family's facts builder took one required cell per operation, so an operation only one owner implements still cost a hand-written denial in every other owner. The builder now takes the family's denial once, as `unsupported`, and every operation cell is optional: an owner names what it implements and omission reports that denial verbatim, with the reason and hint the owner would otherwise have repeated per cell. Omission stays a classified refusal, never an unclassified cell and never an implied success: `unsupported` is required, so a call that leaves the family blank does not compile. Facts are unchanged for every owner, so the per-platform admission assertions hold untouched. Refs #2412 --- .../src/platform-runtime-unavailable.ts | 11 +-- packages/contracts/src/touch-runtime.test.ts | 23 +++++- packages/contracts/src/touch-runtime.ts | 71 +++++++++++-------- packages/platform-android/src/runtime.ts | 6 +- packages/platform-apple/src/runtime.ts | 7 +- packages/platform-harmonyos/src/runtime.ts | 6 +- packages/platform-linux/src/runtime.ts | 6 +- packages/platform-web/src/runtime.ts | 3 +- .../src/interaction-operations.ts | 12 ++-- .../src/platform-runtime.ts | 6 +- .../test-utils/runtime-operation-facts.ts | 8 +-- .../__tests__/screenshot-runtime-fixture.ts | 8 +-- .../handlers/__tests__/install-source.test.ts | 8 +-- .../provider-device-runtime.fixtures.ts | 3 +- 14 files changed, 78 insertions(+), 100 deletions(-) diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 6b1f6da1a2..2d81723803 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -235,16 +235,7 @@ export function createUnavailablePlatformRuntimeFacts( }), ...scrollRuntimeOperationFacts({ scroll: frozen.scroll }), ...typeTextRuntimeOperationFacts({ type: frozen.typeText }), - ...touchRuntimeOperationFacts({ - tap: frozen.touch, - tapRef: frozen.touch, - longPress: frozen.touch, - hover: frozen.touch, - hoverRef: frozen.touch, - fill: frozen.touch, - fillRef: frozen.touch, - tapElementSelector: frozen.touch, - }), + ...touchRuntimeOperationFacts({ unsupported: frozen.touch }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }), ...backRuntimeOperationFacts({ back: frozen.back }), ...homeRuntimeOperationFacts({ home: frozen.home }), diff --git a/packages/contracts/src/touch-runtime.test.ts b/packages/contracts/src/touch-runtime.test.ts index b192721ff0..18ad7f53c1 100644 --- a/packages/contracts/src/touch-runtime.test.ts +++ b/packages/contracts/src/touch-runtime.test.ts @@ -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', () => { @@ -39,6 +37,25 @@ 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 })).toEqual({ + tapPoint: available, + tapRef: denial, + longPressPoint: denial, + hoverPoint: denial, + hoverRef: denial, + fillPoint: denial, + 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); diff --git a/packages/contracts/src/touch-runtime.ts b/packages/contracts/src/touch-runtime.ts index 2fc7ad09f7..5b17656219 100644 --- a/packages/contracts/src/touch-runtime.ts +++ b/packages/contracts/src/touch-runtime.ts @@ -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<{ @@ -74,42 +74,51 @@ 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. Every operation is optional and `unsupported` is + * not: an owner names the operations it implements and states, once, the denial every operation it + * left out reports. Adding a touch operation therefore costs an edit only in the owner that gained + * it — an owner that does not implement it already classified it, by the same `unsupported` cell, + * with the same reason and hint it would have written out by hand. + * + * 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, - 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, - }), - fillPoint: input.fill, - fillRef: input.fillRef ?? NATIVE_REF_UNAVAILABLE, - tapElementSelector: input.tapElementSelector, + tapPoint: declared(input.tap), + tapRef: declared(input.tapRef), + longPressPoint: declared(input.longPress), + hoverPoint: withHoverRefusalHint(declared(input.hover)), + hoverRef: withHoverRefusalHint(declared(input.hoverRef)), + fillPoint: declared(input.fill), + 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 }; diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 682677370a..e52f088598 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -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. diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 651f403eb9..f20cffed4e 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -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), diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 3ed3eed31c..8c6f1136a0 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -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. diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 68b598dcf1..ff63625ac3 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -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, }); } diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index c5504f1163..4c0dea8c68 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -385,14 +385,13 @@ 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 diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 1a08afb15b..a3243fa2b9 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -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), diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index c42251e1d2..69626054d6 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -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 diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 3dfc3dd301..052e1900d3 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -46,13 +46,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre setViewport: unavailable, focusPoint: unavailable, typeText: unavailable, - ...touchRuntimeOperationFacts({ - tap: unavailable, - longPress: unavailable, - hover: unavailable, - fill: unavailable, - tapElementSelector: unavailable, - }), + ...touchRuntimeOperationFacts({ unsupported: unavailable }), ...gestureRuntimeOperationFacts({ plan: unavailable, directionalFling: unavailable, diff --git a/src/daemon/__tests__/screenshot-runtime-fixture.ts b/src/daemon/__tests__/screenshot-runtime-fixture.ts index ecb462ba49..4c4281abc9 100644 --- a/src/daemon/__tests__/screenshot-runtime-fixture.ts +++ b/src/daemon/__tests__/screenshot-runtime-fixture.ts @@ -101,13 +101,7 @@ export function screenshotRuntimeFixture( customActions: options.snapshot ?? available, withoutActiveApp: options.snapshot ?? available, }), - ...touchRuntimeOperationFacts({ - tap: available, - longPress: unavailable, - hover: unavailable, - fill: unavailable, - tapElementSelector: unavailable, - }), + ...touchRuntimeOperationFacts({ unsupported: unavailable, tap: available }), }, }; }; diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 173a0a9769..c7ac8bb0c4 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -363,13 +363,7 @@ function sourceRuntimeFacts( setViewport: unavailable, focusPoint: unavailable, typeText: unavailable, - ...touchRuntimeOperationFacts({ - tap: unavailable, - longPress: unavailable, - hover: unavailable, - fill: unavailable, - tapElementSelector: unavailable, - }), + ...touchRuntimeOperationFacts({ unsupported: unavailable }), ...gestureRuntimeOperationFacts({ plan: unavailable, directionalFling: unavailable, diff --git a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts index e9ef26fa01..b154e18d35 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts @@ -270,11 +270,10 @@ function providerScenarioRuntimeFacts( keyboardDismiss: fakeProviderAvailable, keyboardEnter: fakeProviderAvailable, ...touchRuntimeOperationFacts({ + unsupported: fakeProviderUnavailable, tap: fakeProviderAvailable, longPress: fakeProviderAvailable, - hover: fakeProviderUnavailable, fill: fakeProviderAvailable, - tapElementSelector: fakeProviderUnavailable, }), deployApp: runtime.installApp ? fakeProviderAvailable : fakeProviderUnavailable, ...applicationLifecycleOperationFacts({ From 898c309825749863f39f22e6b17ffd9f652a0f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 11:52:32 +0200 Subject: [PATCH 2/4] fix(contracts): keep tap/longPress/fill required in touch facts Making every touch cell optional dropped the compile-time guard on the three operations every owner implements, with nothing replacing it: an owner could silently drop `tap` and nothing would fail. Only the truly per-owner cells (tapRef, hover, hoverRef, fillRef, tapElementSelector) need `unsupported` as their default. Restoring the required fields caught a real regression this refactor introduced: platform-web's touch facts had silently lost their explicit `longPress: readinessUnavailable` cell, so longPress reported the family's `unsupported` fallback instead of the owner's own readiness reason. Restored it, and fixed the other now-required-field call sites (the fully-unavailable owner fixtures, and the screenshot-runtime-fixture test double, which had also silently lost its longPress/fill distinction). Refs #2412 --- .../src/platform-runtime-unavailable.ts | 7 +++++- packages/contracts/src/touch-runtime.test.ts | 13 ++++++++--- packages/contracts/src/touch-runtime.ts | 23 ++++++++++--------- packages/platform-web/src/runtime.ts | 1 + .../test-utils/runtime-operation-facts.ts | 7 +++++- .../__tests__/screenshot-runtime-fixture.ts | 7 +++++- .../handlers/__tests__/install-source.test.ts | 7 +++++- 7 files changed, 47 insertions(+), 18 deletions(-) diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 2d81723803..796969c6ba 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -235,7 +235,12 @@ export function createUnavailablePlatformRuntimeFacts( }), ...scrollRuntimeOperationFacts({ scroll: frozen.scroll }), ...typeTextRuntimeOperationFacts({ type: frozen.typeText }), - ...touchRuntimeOperationFacts({ unsupported: frozen.touch }), + ...touchRuntimeOperationFacts({ + unsupported: frozen.touch, + tap: frozen.touch, + longPress: frozen.touch, + fill: frozen.touch, + }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }), ...backRuntimeOperationFacts({ back: frozen.back }), ...homeRuntimeOperationFacts({ home: frozen.home }), diff --git a/packages/contracts/src/touch-runtime.test.ts b/packages/contracts/src/touch-runtime.test.ts index 18ad7f53c1..736a279cae 100644 --- a/packages/contracts/src/touch-runtime.test.ts +++ b/packages/contracts/src/touch-runtime.test.ts @@ -44,13 +44,20 @@ test("an operation the owner never names reports the owner's own denial verbatim hint: 'focus is supported on Android emulators and physical devices.', } as const; - expect(touchRuntimeOperationFacts({ unsupported: denial, tap: available })).toEqual({ + expect( + touchRuntimeOperationFacts({ + unsupported: denial, + tap: available, + longPress: available, + fill: available, + }), + ).toEqual({ tapPoint: available, tapRef: denial, - longPressPoint: denial, + longPressPoint: available, hoverPoint: denial, hoverRef: denial, - fillPoint: denial, + fillPoint: available, fillRef: denial, tapElementSelector: denial, }); diff --git a/packages/contracts/src/touch-runtime.ts b/packages/contracts/src/touch-runtime.ts index 5b17656219..04a360ace1 100644 --- a/packages/contracts/src/touch-runtime.ts +++ b/packages/contracts/src/touch-runtime.ts @@ -75,23 +75,24 @@ 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. Every operation is optional and `unsupported` is - * not: an owner names the operations it implements and states, once, the denial every operation it - * left out reports. Adding a touch operation therefore costs an edit only in the owner that gained - * it — an owner that does not implement it already classified it, by the same `unsupported` cell, - * with the same reason and hint it would have written out by hand. + * 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; + tap: RuntimeOperationFact; tapRef?: RuntimeOperationFact; - longPress?: RuntimeOperationFact; + longPress: RuntimeOperationFact; hover?: RuntimeOperationFact; hoverRef?: RuntimeOperationFact; - fill?: RuntimeOperationFact; + fill: RuntimeOperationFact; fillRef?: RuntimeOperationFact; tapElementSelector?: RuntimeOperationFact; }>; @@ -102,12 +103,12 @@ export function touchRuntimeOperationFacts( const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact => fact ?? input.unsupported; return Object.freeze({ - tapPoint: declared(input.tap), + tapPoint: input.tap, tapRef: declared(input.tapRef), - longPressPoint: declared(input.longPress), + longPressPoint: input.longPress, hoverPoint: withHoverRefusalHint(declared(input.hover)), hoverRef: withHoverRefusalHint(declared(input.hoverRef)), - fillPoint: declared(input.fill), + fillPoint: input.fill, fillRef: declared(input.fillRef), tapElementSelector: declared(input.tapElementSelector), }); diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 4c0dea8c68..83b319c46d 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -388,6 +388,7 @@ function webRuntimeFacts( unsupported: readinessUnavailable, tap: browserDevice, tapRef: webOptionalOperationFact(interactor?.tapRef, browserDevice), + longPress: readinessUnavailable, hover: webOptionalOperationFact(interactor?.hover, browserDevice), hoverRef: webOptionalOperationFact(interactor?.hoverRef, browserDevice), fill: browserDevice, diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 052e1900d3..5bf427d638 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -46,7 +46,12 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre setViewport: unavailable, focusPoint: unavailable, typeText: unavailable, - ...touchRuntimeOperationFacts({ unsupported: unavailable }), + ...touchRuntimeOperationFacts({ + unsupported: unavailable, + tap: unavailable, + longPress: unavailable, + fill: unavailable, + }), ...gestureRuntimeOperationFacts({ plan: unavailable, directionalFling: unavailable, diff --git a/src/daemon/__tests__/screenshot-runtime-fixture.ts b/src/daemon/__tests__/screenshot-runtime-fixture.ts index 4c4281abc9..938daca702 100644 --- a/src/daemon/__tests__/screenshot-runtime-fixture.ts +++ b/src/daemon/__tests__/screenshot-runtime-fixture.ts @@ -101,7 +101,12 @@ export function screenshotRuntimeFixture( customActions: options.snapshot ?? available, withoutActiveApp: options.snapshot ?? available, }), - ...touchRuntimeOperationFacts({ unsupported: unavailable, tap: available }), + ...touchRuntimeOperationFacts({ + unsupported: unavailable, + tap: available, + longPress: unavailable, + fill: unavailable, + }), }, }; }; diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index c7ac8bb0c4..cd17c9be2a 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -363,7 +363,12 @@ function sourceRuntimeFacts( setViewport: unavailable, focusPoint: unavailable, typeText: unavailable, - ...touchRuntimeOperationFacts({ unsupported: unavailable }), + ...touchRuntimeOperationFacts({ + unsupported: unavailable, + tap: unavailable, + longPress: unavailable, + fill: unavailable, + }), ...gestureRuntimeOperationFacts({ plan: unavailable, directionalFling: unavailable, From d04527e40192bb7959e718817be06148686dbd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 12:38:35 +0200 Subject: [PATCH 3/4] fix(coverage): repoint hover evidence off the retired runtime.ts source line The touch family's additive-facts refactor dropped every owner's explicit hover: unavailable cell in favor of the family-level unsupported fallback, so the coverage manifests' literal-substring checks against packages/platform-apple/src/runtime.ts and packages/platform-linux/src/runtime.ts broke: that source line no longer exists. Point the macOS, iOS, tvOS, and Linux hover rows at new tests that assert the typed unavailable denial directly against each owner's bound facts, instead of re-adding a hand-written denial line the refactor was meant to retire. Refs #2412 --- packages/platform-apple/src/runtime.test.ts | 24 +++++++++++++++++++++ packages/platform-linux/src/runtime.test.ts | 19 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index e1cea0e10d..8ee329d42c 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -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'; @@ -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, diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 5d050a8d5d..29f57dad51 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -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'; @@ -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: { From e23d92e94c0f32133e1151491769bfbfcee18794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 16:59:03 +0200 Subject: [PATCH 4/4] test(coverage): point hover evidence at the typed-denial tests in the declaration table --- .../command-coverage/declarations.ts | 18 ++++++++++-------- test/integration/command-coverage/evidence.ts | 8 ++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 2e704fc1ea..5442a808d1 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -18,6 +18,8 @@ import { ANDROID_HOVER_RUNTIME_CONTRACT_EVIDENCE, ANDROID_TV_REMOTE_RUNTIME_CONTRACT_EVIDENCE, ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE, + APPLE_HOVER_DENIAL_EVIDENCE, + LINUX_HOVER_DENIAL_EVIDENCE, LINUX_PROVIDER_EVIDENCE, LINUX_RUNTIME_EVIDENCE, TVOS_AUDIO_EVIDENCE, @@ -894,18 +896,18 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'Android runtime facts reject hover with the pointer-only web contract hint', ), iosSimulator: iosSimulator.contract( - 'packages/platform-apple/src/runtime.ts', - 'hover: unavailable', + APPLE_HOVER_DENIAL_EVIDENCE.path, + APPLE_HOVER_DENIAL_EVIDENCE.test, 'the Apple runtime fact rejects hover, a pointer-only web contract', ), macos: macos.contract( - 'packages/platform-apple/src/runtime.ts', - 'hover: unavailable', + APPLE_HOVER_DENIAL_EVIDENCE.path, + APPLE_HOVER_DENIAL_EVIDENCE.test, 'the Apple runtime fact rejects pointer-only hover input on macOS', ), tvos: tvos.contract( - 'packages/platform-apple/src/runtime.ts', - 'hover: unavailable', + APPLE_HOVER_DENIAL_EVIDENCE.path, + APPLE_HOVER_DENIAL_EVIDENCE.test, 'the Apple runtime fact rejects pointer-only hover input on tvOS', ), web: web.contract( @@ -914,8 +916,8 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'web hover moves the pointer through the provider element handle', ), linux: linux.contract( - 'packages/platform-linux/src/runtime.ts', - 'hover: unsupportedPlatformLeaf', + LINUX_HOVER_DENIAL_EVIDENCE.path, + LINUX_HOVER_DENIAL_EVIDENCE.test, 'the Linux runtime fact rejects pointer-only hover input', ), }, diff --git a/test/integration/command-coverage/evidence.ts b/test/integration/command-coverage/evidence.ts index ed91998203..ac7a120b95 100644 --- a/test/integration/command-coverage/evidence.ts +++ b/test/integration/command-coverage/evidence.ts @@ -55,6 +55,10 @@ export const TVOS_AUDIO_EVIDENCE: RepositoryEvidence = { path: 'packages/platform-apple/src/runtime.test.ts', test: 'tvOS audio capture availability follows the exact host-owned runtime fact', }; +export const APPLE_HOVER_DENIAL_EVIDENCE: RepositoryEvidence = { + path: 'packages/platform-apple/src/runtime.test.ts', + test: 'hover has no Apple interactor route on macOS, iOS, or tvOS; the touch family reports its typed denial', +}; export const WEB_SMOKE_TEST_NAME = 'live web platform e2e smoke'; export const WEB_SMOKE_EVIDENCE: RepositoryEvidence = { @@ -78,3 +82,7 @@ export const LINUX_RUNTIME_EVIDENCE: RepositoryEvidence = { path: 'packages/platform-linux/src/runtime.test.ts', test: 'classifies the Linux $name lifecycle denominator against the legacy dispatch cell', }; +export const LINUX_HOVER_DENIAL_EVIDENCE: RepositoryEvidence = { + path: 'packages/platform-linux/src/runtime.test.ts', + test: 'hover has no Linux interactor route; the touch family reports its typed denial', +};