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,