From 880c9f4367a430948170cf562df9387b2aaf4655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 10 Sep 2026 14:27:18 +0200 Subject: [PATCH] refactor(contracts): make keyboard capability facts additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard 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 serves 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. An owner that names every operation still states the family refusal, and it must refuse the family rather than one operation of it — whatever the owner leaves unnamed reports that cell verbatim. The shared unavailable-facts input collapses `keyboardStatus`/`keyboardDismiss`/ `keyboardEnter` into one required `keyboard` cell, so the owners that answered the family with raw keys (webdriver, vega, linux, limrun's no-session binding) now answer it through the builder, which is the family's one entry point. Facts are unchanged for every owner, so the per-platform admission assertions hold untouched. Two owner tests now assert the keyboard reason as well as the availability, because one family cell reports one reason for all three operations: the WebDriver inactive session and the stale Limrun identity. Refs #2443 --- .../contracts/src/keyboard-runtime.test.ts | 42 ++++++++++++++++++- packages/contracts/src/keyboard-runtime.ts | 36 ++++++++++++---- .../src/platform-runtime-unavailable.test.ts | 10 +++-- .../src/platform-runtime-unavailable.ts | 17 +++----- packages/platform-android/src/runtime.ts | 7 ++++ .../platform-apple/src/navigation/runtime.ts | 8 +++- packages/platform-harmonyos/src/runtime.ts | 2 + packages/platform-linux/src/runtime.ts | 4 +- packages/platform-vega/src/runtime.ts | 4 +- packages/platform-web/src/runtime.ts | 6 +-- .../src/app-log-runtime.test.ts | 8 ++++ .../provider-limrun/src/app-log-runtime.ts | 4 +- .../src/interaction-operations.ts | 13 ++++-- .../src/platform-runtime.test.ts | 8 ++++ .../src/platform-runtime.ts | 14 +------ .../test-utils/runtime-operation-facts.ts | 9 ++-- src/daemon/__tests__/keyboard-runtime.test.ts | 32 +++++++------- .../handlers/__tests__/install-source.test.ts | 5 +-- .../session-selector-dispatch.test.ts | 5 +++ .../handlers/__tests__/session-state.test.ts | 8 +--- src/platform-runtime-gateway.test.ts | 8 +--- .../provider-device-runtime.fixtures.ts | 8 +++- 22 files changed, 165 insertions(+), 93 deletions(-) diff --git a/packages/contracts/src/keyboard-runtime.test.ts b/packages/contracts/src/keyboard-runtime.test.ts index 810e67ddf2..15bc088293 100644 --- a/packages/contracts/src/keyboard-runtime.test.ts +++ b/packages/contracts/src/keyboard-runtime.test.ts @@ -17,20 +17,58 @@ const device = { const local = (resolveInteractor: LocalInteractorOperationResolver) => localInteractorSource({ device, resolveInteractor }); -test('builds the exact keyboard operation fact catalog', () => { +const available = { available: true } as const; + +test('builds the exact keyboard operation fact catalog for an owner that names every operation', () => { + const familyDenial = { + available: false, + reason: 'unsupported-device-kind', + } as const; const status = { available: true } as const; const dismiss = { available: false, reason: 'unsupported-platform-leaf', } as const; const enter = { available: true } as const; - expect(keyboardRuntimeOperationFacts({ status, dismiss, enter })).toEqual({ + expect( + keyboardRuntimeOperationFacts({ unsupported: familyDenial, status, dismiss, enter }), + ).toEqual({ keyboardStatus: status, keyboardDismiss: dismiss, keyboardEnter: enter, }); }); +test('an operation the owner never names reports the denial the owner stated for the family, verbatim — omission is a classified refusal, never an unclassified cell and never an implied success', () => { + const denial = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Limrun iOS direct sessions do not expose keyboard actions.', + } as const; + + expect(keyboardRuntimeOperationFacts({ unsupported: denial, dismiss: available })).toEqual({ + keyboardStatus: denial, + keyboardDismiss: available, + keyboardEnter: denial, + }); +}); + +test('an owner serving no keyboard operation names the family denial once and still answers with the exhaustive shape', () => { + const denial = { + available: false, + reason: 'unsupported-platform-leaf', + } as const; + + const facts = keyboardRuntimeOperationFacts({ unsupported: denial }); + + expect(facts).toEqual({ + keyboardStatus: denial, + keyboardDismiss: denial, + keyboardEnter: denial, + }); + expect(Object.isFrozen(facts)).toBe(true); +}); + test('a local status binding drives the interactor and returns its report', async () => { const keyboardStatus = vi.fn(async () => ({ visible: true })); const resolveInteractor = vi.fn(async () => ({ keyboardStatus }) as unknown as Interactor); diff --git a/packages/contracts/src/keyboard-runtime.ts b/packages/contracts/src/keyboard-runtime.ts index 6f96627a05..5903a63974 100644 --- a/packages/contracts/src/keyboard-runtime.ts +++ b/packages/contracts/src/keyboard-runtime.ts @@ -6,7 +6,7 @@ import type { KeyboardStatusResult, 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'; export type { KeyboardDismissResult, KeyboardEnterResult, KeyboardStatusResult }; @@ -43,17 +43,35 @@ export type KeyboardRuntimeOperationFacts = Readonly<{ keyboardEnter: RuntimeOperationFact; }>; +/** + * What an owner declares about the keyboard family. No operation here is one every owner serves, + * and several owners serve no keyboard operation at all, so every operation is optional and + * `unsupported` names the denial an omitted cell reports. An owner with no keyboard surface states + * that denial once instead of writing it out per operation, with the reason and hint it would + * otherwise repeat 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. + * An owner that serves one operation names it — omission means "refuses", never "the same as the + * neighbour" — and its `unsupported` must refuse the family, not one operation of it, because + * whatever the owner leaves unnamed reports that cell verbatim. + */ +export type KeyboardRuntimeOperationFactsInput = Readonly<{ + unsupported: RuntimeOperationUnavailability; + status?: RuntimeOperationFact; + dismiss?: RuntimeOperationFact; + enter?: RuntimeOperationFact; +}>; + export function keyboardRuntimeOperationFacts( - input: Readonly<{ - status: RuntimeOperationFact; - dismiss: RuntimeOperationFact; - enter: RuntimeOperationFact; - }>, + input: KeyboardRuntimeOperationFactsInput, ): KeyboardRuntimeOperationFacts { + const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact => + fact ?? input.unsupported; return Object.freeze({ - keyboardStatus: input.status, - keyboardDismiss: input.dismiss, - keyboardEnter: input.enter, + keyboardStatus: declared(input.status), + keyboardDismiss: declared(input.dismiss), + keyboardEnter: declared(input.enter), }); } diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index fab792057f..306dcd4506 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -47,9 +47,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = { home: { available: false, reason: 'unsupported-provider-mode' }, orientation: { available: false, reason: 'unsupported-provider-mode' }, tvRemote: { available: false, reason: 'unsupported-provider-mode' }, - keyboardStatus: { available: false, reason: 'unsupported-provider-mode' }, - keyboardDismiss: { available: false, reason: 'unsupported-provider-mode' }, - keyboardEnter: { available: false, reason: 'unsupported-provider-mode' }, + keyboard: { available: false, reason: 'unsupported-provider-mode' }, readClipboard: { available: false, reason: 'unsupported-provider-mode' }, writeClipboard: { available: false, reason: 'unsupported-provider-mode' }, appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, @@ -92,6 +90,12 @@ test('generic unavailable binding preserves exact provider ownership and mode', available: false, reason: 'unsupported-provider-mode', }); + for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) { + assert.deepEqual(binding.facts.operations[operation], { + available: false, + reason: 'unsupported-provider-mode', + }); + } // `apps` is left unclassified above (an optional cell): it inherits the network gap's reason. assert.deepEqual(binding.facts.operations.listApps, { available: false, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index d1a9f1fbdb..b4fca03902 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -37,6 +37,9 @@ import { touchRuntimeOperationFacts } from './touch-runtime.ts'; /** * A runtime-contract helper for provider ownership gaps. It never assigns lifecycle semantics: * the selected package/provider must classify every lifecycle operation for its exact cell. + * + * A family is one cell where all of its operations share one reason, and one cell per operation + * only where the reasons genuinely differ per operation. */ export type UnavailablePlatformRuntimeFacts = Readonly<{ appLog: RuntimeOperationUnavailability; @@ -58,9 +61,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ home: RuntimeOperationUnavailability; orientation: RuntimeOperationUnavailability; tvRemote: RuntimeOperationUnavailability; - keyboardStatus: RuntimeOperationUnavailability; - keyboardDismiss: RuntimeOperationUnavailability; - keyboardEnter: RuntimeOperationUnavailability; + keyboard: RuntimeOperationUnavailability; readClipboard: RuntimeOperationUnavailability; writeClipboard: RuntimeOperationUnavailability; appSwitcher: RuntimeOperationUnavailability; @@ -116,9 +117,7 @@ const UNAVAILABLE_CELLS = { home: true, orientation: true, tvRemote: true, - keyboardStatus: true, - keyboardDismiss: true, - keyboardEnter: true, + keyboard: true, readClipboard: true, writeClipboard: true, appSwitcher: true, @@ -245,11 +244,7 @@ export function createUnavailablePlatformRuntimeFacts( ...homeRuntimeOperationFacts({ home: frozen.home }), ...orientationRuntimeOperationFacts({ orientation: frozen.orientation }), ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), - ...keyboardRuntimeOperationFacts({ - status: frozen.keyboardStatus, - dismiss: frozen.keyboardDismiss, - enter: frozen.keyboardEnter, - }), + ...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }), ...clipboardRuntimeOperationFacts({ read: frozen.readClipboard, write: frozen.writeClipboard, diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 92384a8e54..ae4dfa12d5 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -76,6 +76,12 @@ const focusKindUnavailable = Object.freeze({ reason: 'unsupported-device-kind', hint: 'focus is supported on Android emulators and physical devices.', } as const); +/** adb drives keyboard actions on the same two kinds it drives everything else. */ +const keyboardKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'keyboard actions are supported on Android emulators and physical devices.', +} as const); const hoverUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -363,6 +369,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor // The only owner with a live IME status read; dismiss/enter share every other // interaction cell's kind gate (parity with the retired `keyboard` bucket). ...keyboardRuntimeOperationFacts({ + unsupported: keyboardKindUnavailable, status: androidTouchFact(device), dismiss: androidTouchFact(device), enter: androidTouchFact(device), diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts index d7898a50ef..5752eeee66 100644 --- a/packages/platform-apple/src/navigation/runtime.ts +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -101,8 +101,11 @@ function appleTvRemoteFact(device: DeviceInfo): RuntimeOperationFact { : tvRemoteUnavailable; } -/** The outer keyboard cell: unavailable with no hint, matching the retired `supportsKeyboard` - * capability-bucket-level rejection (which carried no hint text of its own). */ +/** + * The outer keyboard cell, and this owner's keyboard-family refusal: unavailable with no hint, + * matching the retired `supportsKeyboard` capability-bucket-level rejection (which carried no hint + * text of its own). + */ const keyboardCellUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -138,6 +141,7 @@ export function appleNavigationFacts(device: DeviceInfo) { ...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }), ...keyboardRuntimeOperationFacts({ + unsupported: keyboardCellUnavailable, status: appleKeyboardStatusFact(device), dismiss: appleKeyboardDismissFact(device), enter: appleKeyboardEnterFact(device), diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 38ea916e35..9424eee648 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -287,7 +287,9 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor }), ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), + // HDC drives dismissal and the enter key; any other keyboard operation is a leaf gap. ...keyboardRuntimeOperationFacts({ + unsupported: harmonyPlatformLeafUnavailable, status: harmonyKeyboardStatusUnavailable, dismiss: harmonyFocusFact(device), enter: harmonyFocusFact(device), diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index ff63625ac3..ab5c30efc3 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -210,9 +210,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts awaitAlert: linuxPlatformLeafUnavailable, acceptAlert: linuxPlatformLeafUnavailable, dismissAlert: linuxPlatformLeafUnavailable, - keyboardStatus: linuxPlatformLeafUnavailable, - keyboardDismiss: linuxPlatformLeafUnavailable, - keyboardEnter: linuxPlatformLeafUnavailable, + keyboard: linuxPlatformLeafUnavailable, audioProbeCapture: linuxAudioProbeUnavailable, audioProbeQuery: linuxAudioProbeUnavailable, perf: linuxPlatformLeafUnavailable, diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index aadc6b4479..100fa1ea85 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -200,9 +200,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts awaitAlert: alertUnavailable, acceptAlert: alertUnavailable, dismissAlert: alertUnavailable, - keyboardStatus: keyboardUnavailable, - keyboardDismiss: keyboardUnavailable, - keyboardEnter: keyboardUnavailable, + keyboard: keyboardUnavailable, audioProbeCapture: audioProbeUnavailable, audioProbeQuery: audioProbeUnavailable, perf: unsupportedPlatformLeaf, diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index eb99437c91..1ed2ee4131 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -413,11 +413,7 @@ function webRuntimeFacts( ...homeRuntimeOperationFacts({ home: navigationUnavailable }), ...orientationRuntimeOperationFacts({ orientation: navigationUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: navigationUnavailable }), - ...keyboardRuntimeOperationFacts({ - status: navigationUnavailable, - dismiss: navigationUnavailable, - enter: navigationUnavailable, - }), + ...keyboardRuntimeOperationFacts({ unsupported: navigationUnavailable }), // The web backend never carried a `clipboard` capability bucket (`WEB_QUERY_COMMANDS` // lists `audio` alone), so no clipboard cell was ever admitted here. ...clipboardRuntimeOperationFacts({ diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 794eb1d7fc..198745d405 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -358,6 +358,14 @@ test('fails closed for a stale Android identity before exposing facts or binding expect(facts.operations.captureScreenshot).toMatchObject({ available: false }); expect(facts.operations.focusPoint).toMatchObject({ available: false }); expect(facts.operations.typeText).toMatchObject({ available: false }); + // Every keyboard operation shares the session gap, not a leaf's own refusal. + for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) { + expect(facts.operations[operation]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + hint: 'Limrun requires a matching live provider session for this device.', + }); + } await expect( owner.bind({ device: staleDevice, intent: { kind: 'ordinary' }, scope }), ).rejects.toMatchObject({ diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index f3eccbfa0b..92562f4d4b 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -108,9 +108,7 @@ export function createLimrunPlatformRuntimeOwner( home: liveSessionUnavailable, orientation: liveSessionUnavailable, tvRemote: liveSessionUnavailable, - keyboardStatus: liveSessionUnavailable, - keyboardDismiss: liveSessionUnavailable, - keyboardEnter: liveSessionUnavailable, + keyboard: liveSessionUnavailable, readClipboard: liveSessionUnavailable, writeClipboard: liveSessionUnavailable, appSwitcher: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index a3243fa2b9..e46cdfd472 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -347,9 +347,16 @@ export function limrunKeyboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, ) { - const cell = - liveSessionUnavailable ?? (device.platform === 'android' ? available : keyboardUnavailableIos); + // One denial covers the iOS leg and any session that is no longer live: neither serves a + // keyboard operation. + const unsupported = liveSessionUnavailable ?? keyboardUnavailableIos; + const androidLegServesKeyboard = + liveSessionUnavailable === undefined && device.platform === 'android'; return Object.freeze({ - ...keyboardRuntimeOperationFacts({ status: cell, dismiss: cell, enter: cell }), + ...keyboardRuntimeOperationFacts( + androidLegServesKeyboard + ? { unsupported, status: available, dismiss: available, enter: available } + : { unsupported }, + ), }); } diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 37801f3e23..81638b314e 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -368,6 +368,14 @@ test.each([ ] as const) { expect(facts.operations[operation].available).toBe(false); } + // `keyboard` is one family cell, so the reason says which gap closed it: the dead session + // refuses with the session gap, a live session with no reachable interactor with this + // provider's own keyboard refusal. + for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) { + expect(facts.operations[operation]).toMatchObject({ + reason: state.isSessionActive() ? 'unsupported-provider-mode' : 'owner-capability-missing', + }); + } if (state.isSessionActive()) { const binding = await owner.bind({ device, diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 69626054d6..45c6068da4 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -29,7 +29,6 @@ import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtim import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; -import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import { @@ -519,9 +518,7 @@ function webDriverFacts( home: inactiveSession, orientation: inactiveSession, tvRemote: inactiveSession, - keyboardStatus: inactiveSession, - keyboardDismiss: inactiveSession, - keyboardEnter: inactiveSession, + keyboard: inactiveSession, readClipboard: inactiveSession, writeClipboard: inactiveSession, appSwitcher: inactiveSession, @@ -565,9 +562,7 @@ function webDriverFacts( home: homeUnavailable, orientation: orientationUnavailable, tvRemote: tvRemoteUnavailable, - keyboardStatus: keyboardUnavailable, - keyboardDismiss: keyboardUnavailable, - keyboardEnter: keyboardUnavailable, + keyboard: keyboardUnavailable, readClipboard: clipboardUnavailable, writeClipboard: clipboardUnavailable, appSwitcher: appSwitcherUnavailable, @@ -648,11 +643,6 @@ function webDriverFacts( orientation: declared('orientation', orientationUnavailable), }), ...tvRemoteRuntimeOperationFacts({ tvRemote: tvRemoteUnavailable }), - ...keyboardRuntimeOperationFacts({ - status: keyboardUnavailable, - dismiss: keyboardUnavailable, - enter: keyboardUnavailable, - }), // Clipboard rides the same reachable interactor `back`/`home` do; the declared-capability // gate stays inside the interactor, where it already lives. // diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index cbc7b86ac2..80999ed3f3 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -2,6 +2,7 @@ import { applicationLifecycleOperationFacts } from '@agent-device/contracts/appl import { audioProbeRuntimeOperationFacts } from '@agent-device/contracts/audio-probe-runtime'; import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import type { RuntimeOperationUnavailability, RuntimeOwnerRef, @@ -64,9 +65,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre home: unavailable, setOrientation: unavailable, tvRemote: unavailable, - keyboardStatus: unavailable, - keyboardDismiss: unavailable, - keyboardEnter: unavailable, + ...keyboardRuntimeOperationFacts({ unsupported: unavailable }), readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, @@ -125,9 +124,7 @@ export function createUnavailableRuntimeFactsForTest( home: fact, orientation: fact, tvRemote: fact, - keyboardStatus: fact, - keyboardDismiss: fact, - keyboardEnter: fact, + keyboard: fact, readClipboard: fact, writeClipboard: fact, appSwitcher: fact, diff --git a/src/daemon/__tests__/keyboard-runtime.test.ts b/src/daemon/__tests__/keyboard-runtime.test.ts index adf80d8851..2f76f4b2c9 100644 --- a/src/daemon/__tests__/keyboard-runtime.test.ts +++ b/src/daemon/__tests__/keyboard-runtime.test.ts @@ -3,6 +3,7 @@ import { keyboardRuntimeOperationFacts, type KeyboardDismissResult, type KeyboardEnterResult, + type KeyboardRuntimeOperationFactsInput, type KeyboardStatusResult, } from '@agent-device/contracts/keyboard-runtime'; import { @@ -10,7 +11,6 @@ import { narrowDeviceBinding, type DeviceBinding, type RuntimeFacts, - type RuntimeOperationFact, } from '@agent-device/contracts/platform-runtime'; import { keyboardDismissUse, @@ -60,11 +60,7 @@ const unavailable = Object.freeze({ function runtimeHarness( device: DeviceInfo, owner: string, - facts: Readonly<{ - status: RuntimeOperationFact; - dismiss: RuntimeOperationFact; - enter: RuntimeOperationFact; - }>, + facts: KeyboardRuntimeOperationFactsInput, ) { const keyboardStatus = vi.fn<() => Promise>(async () => ({ kind: 'ime-probe', @@ -100,6 +96,7 @@ function runtimeHarness( test('android status admits keyboardStatusUse and reports the platform-shaped state', async () => { const harness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, @@ -140,6 +137,7 @@ test('android status admits keyboardStatusUse and reports the platform-shaped st test('`get` is an alias for `status`', async () => { const harness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, @@ -158,6 +156,7 @@ test('`get` is an alias for `status`', async () => { test('`return` is an alias for `enter`', async () => { const harness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, @@ -176,7 +175,7 @@ test('`return` is an alias for `enter`', async () => { test('android status is refused on iOS with the retired in-handler hint', async () => { const harness = runtimeHarness(iosDevice, 'apple', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -204,7 +203,7 @@ test('android status is refused on iOS with the retired in-handler hint', async test('iOS dismiss reports the mechanism disclosure and its own message', async () => { const harness = runtimeHarness(iosDevice, 'apple', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -241,7 +240,7 @@ test('iOS dismiss reports the mechanism disclosure and its own message', async ( // value must degrade to the bare message rather than a false claim. test('iOS dismiss degrades an unrecognized mechanism to the bare message', async () => { const harness = runtimeHarness(iosDevice, 'apple', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -267,7 +266,7 @@ test('iOS dismiss degrades an unrecognized mechanism to the bare message', async test('iOS dismiss omits a message mechanism claim when every mechanism failed', async () => { const harness = runtimeHarness(iosDevice, 'apple', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -296,7 +295,7 @@ test('iOS dismiss omits a message mechanism claim when every mechanism failed', test('iOS dismiss omits a mechanism claim when the keyboard was never visible', async () => { const harness = runtimeHarness(iosDevice, 'apple', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -325,7 +324,7 @@ test('iOS dismiss omits a mechanism claim when the keyboard was never visible', test('harmonyos dismiss reports success with no structured fields beyond the message', async () => { const harness = runtimeHarness(harmonyDevice, 'harmonyos', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -348,6 +347,7 @@ test('harmonyos dismiss reports success with no structured fields beyond the mes test('android dismiss reports the full IME probe evidence', async () => { const harness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, @@ -392,7 +392,7 @@ test('android dismiss reports the full IME probe evidence', async () => { test('iOS enter reports visibility evidence; android enter reports only success', async () => { const iosHarness = runtimeHarness(iosDevice, 'apple', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -419,6 +419,7 @@ test('iOS enter reports visibility evidence; android enter reports only success' } const androidHarness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, @@ -441,7 +442,7 @@ test('iOS enter reports visibility evidence; android enter reports only success' test('harmonyos enter reports only success, distinctly from android despite an identical shape', async () => { const harness = runtimeHarness(harmonyDevice, 'harmonyos', { - status: unavailable, + unsupported: unavailable, dismiss: available, enter: available, }); @@ -464,6 +465,7 @@ test('harmonyos enter reports only success, distinctly from android despite an i test('rejects an unknown subcommand before inspection or binding', async () => { const harness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, @@ -483,6 +485,7 @@ test('rejects an unknown subcommand before inspection or binding', async () => { test('rejects more than one subcommand argument', async () => { const harness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, @@ -500,6 +503,7 @@ test('rejects more than one subcommand argument', async () => { test('defaults to status with no positional', async () => { const harness = runtimeHarness(androidDevice, 'android', { + unsupported: unavailable, status: available, dismiss: available, enter: available, diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 138ecba64f..31da7ffa42 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -15,6 +15,7 @@ import { import { audioProbeRuntimeOperationFacts } from '@agent-device/contracts/audio-probe-runtime'; import { perfRuntimeOperationFacts } from '@agent-device/contracts/perf-runtime'; import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; @@ -381,9 +382,7 @@ function sourceRuntimeFacts( home: unavailable, setOrientation: unavailable, tvRemote: unavailable, - keyboardStatus: unavailable, - keyboardDismiss: unavailable, - keyboardEnter: unavailable, + ...keyboardRuntimeOperationFacts({ unsupported: unavailable }), readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index 88d44f87c1..915f047091 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -30,6 +30,10 @@ import { handleSessionCommands } from './session-command-harness.ts'; import { refFrameState } from '../../ref-frame.ts'; const available = Object.freeze({ available: true } as const); +const keyboardFamilyDenial = Object.freeze({ + available: false, + reason: 'owner-capability-missing' as const, +}); /** Admits every keyboard operation so the ADR 0014 seam runs on real admission, not a rejection. * `keyboardDismiss` is overridable so a test can force the invocation itself to reject, proving @@ -41,6 +45,7 @@ function keyboardCapableRuntime( const facts: RuntimeFacts = { device: { ...deviceShape(device), providerMode: 'local' }, operations: keyboardRuntimeOperationFacts({ + unsupported: keyboardFamilyDenial, status: available, dismiss: available, enter: available, diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 1c580330b1..27485b29c0 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -58,9 +58,7 @@ test('boot rejects --headless outside Android directly', async () => { home: { available: false, reason: 'owner-capability-missing' }, orientation: { available: false, reason: 'owner-capability-missing' }, tvRemote: { available: false, reason: 'owner-capability-missing' }, - keyboardStatus: { available: false, reason: 'owner-capability-missing' }, - keyboardDismiss: { available: false, reason: 'owner-capability-missing' }, - keyboardEnter: { available: false, reason: 'owner-capability-missing' }, + keyboard: { available: false, reason: 'owner-capability-missing' }, readClipboard: { available: false, reason: 'owner-capability-missing' }, writeClipboard: { available: false, reason: 'owner-capability-missing' }, appSwitcher: { available: false, reason: 'owner-capability-missing' }, @@ -167,9 +165,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () home: { available: false, reason: 'unsupported-platform-leaf' }, orientation: { available: false, reason: 'unsupported-platform-leaf' }, tvRemote: { available: false, reason: 'unsupported-platform-leaf' }, - keyboardStatus: { available: false, reason: 'unsupported-platform-leaf' }, - keyboardDismiss: { available: false, reason: 'unsupported-platform-leaf' }, - keyboardEnter: { available: false, reason: 'unsupported-platform-leaf' }, + keyboard: { available: false, reason: 'unsupported-platform-leaf' }, readClipboard: { available: false, reason: 'unsupported-platform-leaf' }, writeClipboard: { available: false, reason: 'unsupported-platform-leaf' }, appSwitcher: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index f5ab765ccb..ff2e292b82 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -66,9 +66,7 @@ describe('composed platform runtime gateway', () => { home: unavailable, orientation: unavailable, tvRemote: unavailable, - keyboardStatus: unavailable, - keyboardDismiss: unavailable, - keyboardEnter: unavailable, + keyboard: unavailable, readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, @@ -167,9 +165,7 @@ describe('composed platform runtime gateway', () => { home: unavailable, orientation: unavailable, tvRemote: unavailable, - keyboardStatus: unavailable, - keyboardDismiss: unavailable, - keyboardEnter: unavailable, + keyboard: unavailable, readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, diff --git a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts index b154e18d35..25ac9ddcbc 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts @@ -21,6 +21,7 @@ import { invokeApplicationClose, invokeApplicationOpen, } from '@agent-device/contracts/application-lifecycle-interaction'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { bindProviderSnapshotInteractor } from '@agent-device/contracts/snapshot-runtime'; import { bindProviderTouchInteractor, @@ -267,8 +268,11 @@ function providerScenarioRuntimeFacts( captureSnapshot: fakeProviderAvailable, // Provider-owned iOS keyboard actions ride the same runner transport the shared interactor // does (#1297): a fixture scenario that can drive the interactor at all can drive these. - keyboardDismiss: fakeProviderAvailable, - keyboardEnter: fakeProviderAvailable, + ...keyboardRuntimeOperationFacts({ + unsupported: fakeProviderUnavailable, + dismiss: fakeProviderAvailable, + enter: fakeProviderAvailable, + }), ...touchRuntimeOperationFacts({ unsupported: fakeProviderUnavailable, tap: fakeProviderAvailable,