From e1e34ae08713a2f68f1040deefec96153e8316d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 22 Aug 2026 08:11:42 +0200 Subject: [PATCH 01/13] refactor: migrate back/home/orientation/tv-remote/keyboard to the request-bound device runtime Continues the ADR 0019 platform-runtime migration (Wave 5 generic leaves): five generic-route commands move off dispatchKnownCommand/Interactor legacy dispatch onto fact-owned admission, one bind per handler. keyboard uses the R35 action-selected single-bind pattern (status/dismiss/enter each admit and bind independently). All 8 owner runtime packages gained fact-cell tests for the new operations; six smoke-coverage integration oracles and nine daemon/capability unit test files were updated for the retired capability- catalog admission these commands no longer carry. --- packages/contracts/src/back-runtime.test.ts | 87 ++++ packages/contracts/src/back-runtime.ts | 86 ++++ packages/contracts/src/facades/interaction.ts | 3 + packages/contracts/src/facades/platform.ts | 79 +++ packages/contracts/src/home-runtime.test.ts | 86 ++++ packages/contracts/src/home-runtime.ts | 79 +++ packages/contracts/src/interactor-types.ts | 44 ++ .../contracts/src/keyboard-runtime.test.ts | 152 ++++++ packages/contracts/src/keyboard-runtime.ts | 220 ++++++++ .../contracts/src/orientation-runtime.test.ts | 93 ++++ packages/contracts/src/orientation-runtime.ts | 92 ++++ .../src/platform-runtime-operations.ts | 30 ++ .../src/platform-runtime-unavailable.test.ts | 7 + .../src/platform-runtime-unavailable.ts | 38 ++ .../contracts/src/tv-remote-runtime.test.ts | 90 ++++ packages/contracts/src/tv-remote-runtime.ts | 93 ++++ packages/platform-android/src/runtime.test.ts | 121 +++++ packages/platform-android/src/runtime.ts | 72 +++ packages/platform-apple/src/runtime.test.ts | 64 +++ packages/platform-apple/src/runtime.ts | 289 ++++++++--- .../platform-harmonyos/src/runtime.test.ts | 39 ++ packages/platform-harmonyos/src/runtime.ts | 65 +++ packages/platform-linux/src/runtime.test.ts | 29 ++ packages/platform-linux/src/runtime.ts | 103 ++-- packages/platform-vega/src/runtime.test.ts | 22 + packages/platform-vega/src/runtime.ts | 78 ++- packages/platform-web/src/runtime.test.ts | 23 + packages/platform-web/src/runtime.ts | 21 + .../src/app-log-runtime.test.ts | 43 ++ .../provider-limrun/src/app-log-runtime.ts | 92 +++- .../src/interaction-operations.test.ts | 130 +++++ .../src/interaction-operations.ts | 92 ++++ .../src/platform-runtime.test.ts | 32 ++ .../src/platform-runtime.ts | 124 +++-- scripts/layering/daemon-modularity.test.ts | 4 +- scripts/layering/daemon-modularity.ts | 5 +- .../layering/runtime-command-cutover-table.ts | 116 ++++- .../apple-os-capability-table-parity.test.ts | 24 +- .../test-utils/runtime-operation-facts.ts | 7 + src/core/__tests__/capabilities.test.ts | 44 +- .../capability-plugin-routing-parity.test.ts | 73 +-- src/core/__tests__/dispatch-back.test.ts | 47 +- src/core/__tests__/dispatch-keyboard.test.ts | 143 ++---- .../__tests__/dispatch-orientation.test.ts | 63 +-- src/core/__tests__/dispatch-tv-remote.test.ts | 74 +-- src/core/capabilities.ts | 3 - .../__tests__/parity.test.ts | 9 + src/core/command-descriptor/registry.ts | 75 +-- src/core/dispatch.ts | 210 +------- src/core/interactors/android.ts | 9 + src/core/interactors/harmonyos.ts | 9 + src/core/interactors/register-builtins.ts | 5 - src/daemon/__tests__/back-runtime.test.ts | 179 +++++++ src/daemon/__tests__/home-runtime.test.ts | 150 ++++++ src/daemon/__tests__/keyboard-runtime.test.ts | 472 ++++++++++++++++++ .../__tests__/orientation-runtime.test.ts | 213 ++++++++ .../__tests__/request-router-cost.test.ts | 8 +- .../request-router-lock-policy.test.ts | 6 +- .../request-router-replay-scope.test.ts | 4 +- .../request-router-response-level.test.ts | 32 +- .../request-router-typed-error.test.ts | 12 +- .../__tests__/tv-remote-runtime.test.ts | 214 ++++++++ src/daemon/back-runtime.ts | 52 ++ src/daemon/focus-runtime.ts | 26 +- src/daemon/generic-runtime-execution.ts | 31 ++ .../handlers/__tests__/install-source.test.ts | 7 + .../interaction-get-runtime-fixture.ts | 7 + .../session-appstate-input-perf.test.ts | 48 ++ .../session-capabilities.fixtures.ts | 7 + .../__tests__/session-command-harness.ts | 7 + .../handlers/__tests__/session-state.test.ts | 14 + src/daemon/handlers/session.ts | 89 +++- src/daemon/home-runtime.ts | 46 ++ src/daemon/keyboard-runtime.ts | 222 ++++++++ src/daemon/orientation-runtime.ts | 70 +++ src/daemon/runtime-admission.ts | 37 ++ src/daemon/tv-remote-runtime.ts | 79 +++ src/platform-runtime-gateway.test.ts | 14 + src/platform-runtime-gateway.ts | 14 + .../android/__tests__/input-actions.test.ts | 48 ++ .../interactor-runner-provider.test.ts | 2 + src/platforms/apple/interactor.ts | 34 ++ src/platforms/apple/plugin.ts | 36 +- src/platforms/vega/__tests__/plugin.test.ts | 52 +- src/platforms/vega/plugin.ts | 27 - .../android-emulator-e2e/coverage-manifest.ts | 12 +- .../ios-simulator-e2e/coverage-manifest.ts | 13 +- .../linux-e2e/coverage-manifest.ts | 19 +- .../macos-e2e/coverage-manifest.ts | 32 +- .../provider-device-runtime.fixtures.ts | 16 + .../smoke-android-emulator-coverage.test.ts | 12 +- .../smoke-ios-simulator-coverage.test.ts | 13 +- test/integration/smoke-linux-coverage.test.ts | 7 +- test/integration/smoke-macos-coverage.test.ts | 4 +- .../smoke-tvos-platform-coverage.test.ts | 4 +- .../smoke-web-platform-coverage.test.ts | 4 +- .../integration/tvos-e2e/coverage-manifest.ts | 18 +- test/integration/web-e2e/coverage-manifest.ts | 30 +- 98 files changed, 5092 insertions(+), 989 deletions(-) create mode 100644 packages/contracts/src/back-runtime.test.ts create mode 100644 packages/contracts/src/back-runtime.ts create mode 100644 packages/contracts/src/home-runtime.test.ts create mode 100644 packages/contracts/src/home-runtime.ts create mode 100644 packages/contracts/src/keyboard-runtime.test.ts create mode 100644 packages/contracts/src/keyboard-runtime.ts create mode 100644 packages/contracts/src/orientation-runtime.test.ts create mode 100644 packages/contracts/src/orientation-runtime.ts create mode 100644 packages/contracts/src/tv-remote-runtime.test.ts create mode 100644 packages/contracts/src/tv-remote-runtime.ts create mode 100644 packages/provider-limrun/src/interaction-operations.test.ts create mode 100644 src/daemon/__tests__/back-runtime.test.ts create mode 100644 src/daemon/__tests__/home-runtime.test.ts create mode 100644 src/daemon/__tests__/keyboard-runtime.test.ts create mode 100644 src/daemon/__tests__/orientation-runtime.test.ts create mode 100644 src/daemon/__tests__/tv-remote-runtime.test.ts create mode 100644 src/daemon/back-runtime.ts create mode 100644 src/daemon/home-runtime.ts create mode 100644 src/daemon/keyboard-runtime.ts create mode 100644 src/daemon/orientation-runtime.ts create mode 100644 src/daemon/tv-remote-runtime.ts diff --git a/packages/contracts/src/back-runtime.test.ts b/packages/contracts/src/back-runtime.test.ts new file mode 100644 index 0000000000..2a33277ec3 --- /dev/null +++ b/packages/contracts/src/back-runtime.test.ts @@ -0,0 +1,87 @@ +import { expect, test, vi } from 'vitest'; +import { + bindLocalBackInteractor, + bindProviderBackInteractor, + backRuntimeOperationFacts, +} from './back-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact back operation fact catalog', () => { + const back = { available: true } as const; + expect(backRuntimeOperationFacts({ back })).toEqual({ back }); +}); + +test('a local binding drives the interactor with the requested mode', async () => { + const back = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalBackInteractor({ device, signal, resolveInteractor }); + await operations.back({ + mode: 'system', + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'back-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'back-1', + appBundleId: 'com.example.app', + signal, + }); + expect(back).toHaveBeenCalledWith('system'); +}); + +test('a provider binding drives its own resolved interactor', async () => { + const back = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(() => ({ back }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindProviderBackInteractor({ device, signal, resolveInteractor }); + await operations.back({ execution: { requestId: 'back-2' } }); + + expect(resolveInteractor).toHaveBeenCalledWith({ + requestId: 'back-2', + appBundleId: undefined, + signal, + }); + expect(back).toHaveBeenCalledWith(undefined); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderBackInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.back({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const back = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); + + const operations = bindLocalBackInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.back({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(back).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/back-runtime.ts b/packages/contracts/src/back-runtime.ts new file mode 100644 index 0000000000..10ea2e65d7 --- /dev/null +++ b/packages/contracts/src/back-runtime.ts @@ -0,0 +1,86 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { BackMode, Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one back navigation. `mode` is already validated by the caller (the + * `--mode` flag); the operation names no command, request, session, or CLI flag. + */ +export type BackInput = Readonly<{ + mode?: BackMode; + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * Back returns nothing. The legacy leaf discarded whatever the interactor answered and reported + * only the mode it requested, so a result type here would be a surface the command never had. + */ +export type BackRuntimeOperations = Readonly<{ + back(input: BackInput): Promise; +}>; + +export type BackRuntimeOperationFacts = Readonly<{ + back: RuntimeOperationFact; +}>; + +export function backRuntimeOperationFacts( + input: Readonly<{ back: RuntimeOperationFact }>, +): BackRuntimeOperationFacts { + return Object.freeze({ back: input.back }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the navigation itself. + */ +function bindBack( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): BackRuntimeOperations { + return Object.freeze({ + back: async (input: BackInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + await interactor.back(input.mode); + }, + }); +} + +export type LocalBackInteractorResolver = LocalInteractorOperationResolver; + +export function bindLocalBackInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalBackInteractorResolver; + }>, +): BackRuntimeOperations { + return bindBack(params.signal, localInteractorSource(params)); +} + +export type ProviderBackInteractorResolver = ProviderInteractorOperationResolver; + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderBackInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderBackInteractorResolver; + }>, +): BackRuntimeOperations { + return bindBack(params.signal, providerInteractorSource({ ...params, operation: 'back' })); +} diff --git a/packages/contracts/src/facades/interaction.ts b/packages/contracts/src/facades/interaction.ts index 777c56cac4..e7400fc142 100644 --- a/packages/contracts/src/facades/interaction.ts +++ b/packages/contracts/src/facades/interaction.ts @@ -123,6 +123,9 @@ export type { FillUnconfirmedVerification, FillVerificationTarget, Interactor, + KeyboardDismissResult, + KeyboardEnterResult, + KeyboardStatusResult, RunnerCallOptions, RunnerContext, ScreenshotOptions, diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index c3893da258..72c4e03721 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -225,6 +225,14 @@ export { focusRuntimeUse, typeTextRuntimeUse, viewportRuntimeUse, + backRuntimeUse, + homeRuntimeUse, + orientationRuntimeUse, + tvRemoteRuntimeUse, + keyboardRuntimePlanUses, + keyboardStatusUse, + keyboardDismissUse, + keyboardEnterUse, } from '../platform-runtime-operations.ts'; export type { ScreenshotRuntimePlan, @@ -315,6 +323,77 @@ export type { TypeTextRuntimeOperationFacts, TypeTextRuntimeOperations, } from '../type-text-runtime.ts'; +export { + bindLocalBackInteractor, + bindProviderBackInteractor, + backRuntimeOperationFacts, +} from '../back-runtime.ts'; +export type { + BackInput, + BackRuntimeOperationFacts, + BackRuntimeOperations, + LocalBackInteractorResolver, + ProviderBackInteractorResolver, +} from '../back-runtime.ts'; +export { + bindLocalHomeInteractor, + bindProviderHomeInteractor, + homeRuntimeOperationFacts, +} from '../home-runtime.ts'; +export type { + HomeInput, + HomeRuntimeOperationFacts, + HomeRuntimeOperations, + LocalHomeInteractorResolver, + ProviderHomeInteractorResolver, +} from '../home-runtime.ts'; +export { + bindLocalOrientationInteractor, + bindProviderOrientationInteractor, + orientationRuntimeOperationFacts, +} from '../orientation-runtime.ts'; +export type { + LocalOrientationInteractorResolver, + OrientationRuntimeOperationFacts, + OrientationRuntimeOperations, + ProviderOrientationInteractorResolver, + SetOrientationInput, + SetOrientationResult, +} from '../orientation-runtime.ts'; +export { + bindLocalTvRemoteInteractor, + bindProviderTvRemoteInteractor, + tvRemoteRuntimeOperationFacts, +} from '../tv-remote-runtime.ts'; +export type { + LocalTvRemoteInteractorResolver, + ProviderTvRemoteInteractorResolver, + TvRemoteInput, + TvRemoteRuntimeOperationFacts, + TvRemoteRuntimeOperations, +} from '../tv-remote-runtime.ts'; +export { + bindLocalKeyboardStatusInteractor, + bindProviderKeyboardStatusInteractor, + bindLocalKeyboardDismissInteractor, + bindProviderKeyboardDismissInteractor, + bindLocalKeyboardEnterInteractor, + bindProviderKeyboardEnterInteractor, + keyboardRuntimeOperationFacts, +} from '../keyboard-runtime.ts'; +export type { + KeyboardActionInput, + KeyboardDismissResult, + KeyboardDismissRuntimeOperations, + KeyboardEnterResult, + KeyboardEnterRuntimeOperations, + KeyboardRuntimeOperationFacts, + KeyboardRuntimeOperations, + KeyboardStatusResult, + KeyboardStatusRuntimeOperations, + LocalKeyboardInteractorResolver, + ProviderKeyboardInteractorResolver, +} from '../keyboard-runtime.ts'; export { viewportRuntimeOperationFacts } from '../viewport-runtime.ts'; export type { SetViewportInput, diff --git a/packages/contracts/src/home-runtime.test.ts b/packages/contracts/src/home-runtime.test.ts new file mode 100644 index 0000000000..ac95afce8d --- /dev/null +++ b/packages/contracts/src/home-runtime.test.ts @@ -0,0 +1,86 @@ +import { expect, test, vi } from 'vitest'; +import { + bindLocalHomeInteractor, + bindProviderHomeInteractor, + homeRuntimeOperationFacts, +} from './home-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact home operation fact catalog', () => { + const home = { available: true } as const; + expect(homeRuntimeOperationFacts({ home })).toEqual({ home }); +}); + +test('a local binding drives the interactor with no arguments', async () => { + const home = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalHomeInteractor({ device, signal, resolveInteractor }); + await operations.home({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'home-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'home-1', + appBundleId: 'com.example.app', + signal, + }); + expect(home).toHaveBeenCalledWith(); +}); + +test('a provider binding drives its own resolved interactor', async () => { + const home = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(() => ({ home }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindProviderHomeInteractor({ device, signal, resolveInteractor }); + await operations.home({ execution: { requestId: 'home-2' } }); + + expect(resolveInteractor).toHaveBeenCalledWith({ + requestId: 'home-2', + appBundleId: undefined, + signal, + }); + expect(home).toHaveBeenCalledTimes(1); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderHomeInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.home({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const home = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); + + const operations = bindLocalHomeInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.home({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(home).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/home-runtime.ts b/packages/contracts/src/home-runtime.ts new file mode 100644 index 0000000000..e34d9bde28 --- /dev/null +++ b/packages/contracts/src/home-runtime.ts @@ -0,0 +1,79 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** Neutral intent for one home navigation: no arguments, so only runner metadata travels. */ +export type HomeInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** Home returns nothing; the legacy leaf discarded whatever the interactor answered. */ +export type HomeRuntimeOperations = Readonly<{ + home(input: HomeInput): Promise; +}>; + +export type HomeRuntimeOperationFacts = Readonly<{ + home: RuntimeOperationFact; +}>; + +export function homeRuntimeOperationFacts( + input: Readonly<{ home: RuntimeOperationFact }>, +): HomeRuntimeOperationFacts { + return Object.freeze({ home: input.home }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the navigation itself. + */ +function bindHome( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): HomeRuntimeOperations { + return Object.freeze({ + home: async (input: HomeInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + await interactor.home(); + }, + }); +} + +export type LocalHomeInteractorResolver = LocalInteractorOperationResolver; + +export function bindLocalHomeInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalHomeInteractorResolver; + }>, +): HomeRuntimeOperations { + return bindHome(params.signal, localInteractorSource(params)); +} + +export type ProviderHomeInteractorResolver = ProviderInteractorOperationResolver; + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderHomeInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderHomeInteractorResolver; + }>, +): HomeRuntimeOperations { + return bindHome(params.signal, providerInteractorSource({ ...params, operation: 'home' })); +} diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index c3a7aefa4f..9eda2717d2 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -170,6 +170,44 @@ export type SnapshotOptions = BaseSnapshotOptions & { surface?: SessionSurface; }; +/** + * Android's live IME status read. Optional on {@link Interactor}: parity with the retired leaf, + * which refused `status`/`get` on every other family (no other platform's runner exposes one). + */ +export type KeyboardStatusResult = { + visible: boolean; + inputType?: string; + type?: string; + inputMethodPackage?: string; + focusedPackage?: string; + focusedResourceId?: string; + inputOwner?: string; +}; + +/** + * Owner-shaped dismiss evidence: Android's IME probe fields, or iOS's `mechanism` disclosure + * (#1598), or neither when HarmonyOS's HDC key press reports nothing beyond success. + */ +export type KeyboardDismissResult = { + attempts?: number; + wasVisible?: boolean; + dismissed?: boolean; + visible?: boolean; + inputType?: string; + type?: string; + inputMethodPackage?: string; + focusedPackage?: string; + focusedResourceId?: string; + inputOwner?: string; + mechanism?: string; +}; + +/** Only the Apple runner echoes visibility around the return-key press; every other owner is blind. */ +export type KeyboardEnterResult = { + visible?: boolean; + wasVisible?: boolean; +}; + export type SnapshotResult = Omit & { nodes?: RawSnapshotNode[]; backend: Extract< @@ -256,6 +294,12 @@ export type Interactor = { performGesture?(plan: GesturePlan): Promise | void>; appSwitcher(): Promise; tvRemote(button: TvRemoteButton, durationMs?: number): Promise; + /** Optional: only Android implements a live status read (see {@link KeyboardStatusResult}). */ + keyboardStatus?(): Promise; + /** Optional: platforms with no keyboard-dismiss concept leave it undefined. */ + keyboardDismiss?(): Promise; + /** Optional: platforms with no keyboard-return concept leave it undefined. */ + keyboardEnter?(): Promise; readClipboard(): Promise; writeClipboard(text: string): Promise; setSetting( diff --git a/packages/contracts/src/keyboard-runtime.test.ts b/packages/contracts/src/keyboard-runtime.test.ts new file mode 100644 index 0000000000..c1876a9053 --- /dev/null +++ b/packages/contracts/src/keyboard-runtime.test.ts @@ -0,0 +1,152 @@ +import { expect, test, vi } from 'vitest'; +import { + bindLocalKeyboardDismissInteractor, + bindLocalKeyboardEnterInteractor, + bindLocalKeyboardStatusInteractor, + bindProviderKeyboardDismissInteractor, + bindProviderKeyboardEnterInteractor, + bindProviderKeyboardStatusInteractor, + keyboardRuntimeOperationFacts, +} from './keyboard-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact keyboard operation fact catalog', () => { + 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({ + keyboardStatus: status, + keyboardDismiss: dismiss, + keyboardEnter: enter, + }); +}); + +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); + const signal = new AbortController().signal; + + const operations = bindLocalKeyboardStatusInteractor({ device, signal, resolveInteractor }); + const result = await operations.keyboardStatus({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'keyboard-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'keyboard-1', + appBundleId: 'com.example.app', + signal, + }); + expect(keyboardStatus).toHaveBeenCalledTimes(1); + expect(result).toEqual({ visible: true }); +}); + +test('a local dismiss binding drives the interactor', async () => { + const keyboardDismiss = vi.fn(async () => ({ dismissed: true, visible: false })); + const resolveInteractor = vi.fn(async () => ({ keyboardDismiss }) as unknown as Interactor); + const operations = bindLocalKeyboardDismissInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor, + }); + + const result = await operations.keyboardDismiss({}); + + expect(keyboardDismiss).toHaveBeenCalledTimes(1); + expect(result).toEqual({ dismissed: true, visible: false }); +}); + +test('a local enter binding drives the interactor', async () => { + const keyboardEnter = vi.fn(async () => ({})); + const resolveInteractor = vi.fn(async () => ({ keyboardEnter }) as unknown as Interactor); + const operations = bindLocalKeyboardEnterInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor, + }); + + await operations.keyboardEnter({}); + + expect(keyboardEnter).toHaveBeenCalledTimes(1); +}); + +test('a provider binding drives its own resolved interactor', async () => { + const keyboardStatus = vi.fn(async () => ({ visible: false })); + const resolveInteractor = vi.fn(() => ({ keyboardStatus }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindProviderKeyboardStatusInteractor({ device, signal, resolveInteractor }); + await operations.keyboardStatus({ execution: { requestId: 'keyboard-2' } }); + + expect(resolveInteractor).toHaveBeenCalledWith({ + requestId: 'keyboard-2', + appBundleId: undefined, + signal, + }); + expect(keyboardStatus).toHaveBeenCalledTimes(1); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor at all', async () => { + const dismissOperations = bindProviderKeyboardDismissInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + const enterOperations = bindProviderKeyboardEnterInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(dismissOperations.keyboardDismiss({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); + await expect(enterOperations.keyboardEnter({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('binding fails as a runtime-contract error when the resolved interactor has no method — `Interactor.keyboardStatus`/`keyboardDismiss`/`keyboardEnter` are optional, so an owner whose fact admitted the operation but whose interactor omits it is a contract bug, not a normal refusal', async () => { + // The interactor is resolved (unlike the "no interactor at all" case above), but it does not + // implement `keyboardEnter` — parity with `hover`, which platforms with no keyboard concept + // simply omit. + const resolveInteractor = vi.fn(async () => ({}) as unknown as Interactor); + const operations = bindLocalKeyboardEnterInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor, + }); + + await expect(operations.keyboardEnter({})).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'interactor-method-missing' }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const keyboardStatus = vi.fn(async () => ({ visible: true })); + const resolveInteractor = vi.fn(async () => ({ keyboardStatus }) as unknown as Interactor); + + const operations = bindLocalKeyboardStatusInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.keyboardStatus({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(keyboardStatus).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/keyboard-runtime.ts b/packages/contracts/src/keyboard-runtime.ts new file mode 100644 index 0000000000..489ed36b88 --- /dev/null +++ b/packages/contracts/src/keyboard-runtime.ts @@ -0,0 +1,220 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { + Interactor, + KeyboardDismissResult, + KeyboardEnterResult, + KeyboardStatusResult, + RunnerContext, +} from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +export type { KeyboardDismissResult, KeyboardEnterResult, KeyboardStatusResult }; + +/** + * Neutral intent for one keyboard probe/action. Every keyboard operation takes the same shape — + * runner metadata only, no arguments — so one input type covers all three; the action itself is + * which operation the caller invokes, decided by the daemon's action-selected bind (ADR 0019 §9), + * never by an argument threaded through here. + */ +export type KeyboardActionInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +export type KeyboardStatusRuntimeOperations = Readonly<{ + keyboardStatus(input: KeyboardActionInput): Promise; +}>; +export type KeyboardDismissRuntimeOperations = Readonly<{ + keyboardDismiss(input: KeyboardActionInput): Promise; +}>; +export type KeyboardEnterRuntimeOperations = Readonly<{ + keyboardEnter(input: KeyboardActionInput): Promise; +}>; + +export type KeyboardRuntimeOperations = KeyboardStatusRuntimeOperations & + KeyboardDismissRuntimeOperations & + KeyboardEnterRuntimeOperations; + +export type KeyboardRuntimeOperationFacts = Readonly<{ + keyboardStatus: RuntimeOperationFact; + keyboardDismiss: RuntimeOperationFact; + keyboardEnter: RuntimeOperationFact; +}>; + +export function keyboardRuntimeOperationFacts( + input: Readonly<{ + status: RuntimeOperationFact; + dismiss: RuntimeOperationFact; + enter: RuntimeOperationFact; + }>, +): KeyboardRuntimeOperationFacts { + return Object.freeze({ + keyboardStatus: input.status, + keyboardDismiss: input.dismiss, + keyboardEnter: input.enter, + }); +} + +/** + * `Interactor.keyboardStatus`/`keyboardDismiss`/`keyboardEnter` are optional (parity with + * `hover`): a platform with no keyboard concept for that action leaves it undefined. Facts admit + * an operation only for owners whose interactor implements it, so a missing method at bind time + * is a runtime-contract error, not a normal refusal. + */ +function requireKeyboardMethod( + method: Method | undefined, + operation: string, +): NonNullable { + if (method) return method as NonNullable; + throw new AppError( + 'COMMAND_FAILED', + `${operation} was admitted but its bound interactor has no implementation.`, + { reason: 'interactor-method-missing' }, + ); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the probe itself. + */ +function bindKeyboardStatus( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): KeyboardStatusRuntimeOperations { + return Object.freeze({ + keyboardStatus: async (input: KeyboardActionInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + return await requireKeyboardMethod(interactor.keyboardStatus, 'keyboard status').call( + interactor, + ); + }, + }); +} + +function bindKeyboardDismiss( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): KeyboardDismissRuntimeOperations { + return Object.freeze({ + keyboardDismiss: async (input: KeyboardActionInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + return await requireKeyboardMethod(interactor.keyboardDismiss, 'keyboard dismiss').call( + interactor, + ); + }, + }); +} + +function bindKeyboardEnter( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): KeyboardEnterRuntimeOperations { + return Object.freeze({ + keyboardEnter: async (input: KeyboardActionInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + return await requireKeyboardMethod(interactor.keyboardEnter, 'keyboard enter').call( + interactor, + ); + }, + }); +} + +export type LocalKeyboardInteractorResolver = LocalInteractorOperationResolver; +export type ProviderKeyboardInteractorResolver = ProviderInteractorOperationResolver; + +export function bindLocalKeyboardStatusInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalKeyboardInteractorResolver; + }>, +): KeyboardStatusRuntimeOperations { + return bindKeyboardStatus(params.signal, localInteractorSource(params)); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderKeyboardStatusInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderKeyboardInteractorResolver; + }>, +): KeyboardStatusRuntimeOperations { + return bindKeyboardStatus( + params.signal, + providerInteractorSource({ ...params, operation: 'keyboard status' }), + ); +} + +export function bindLocalKeyboardDismissInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalKeyboardInteractorResolver; + }>, +): KeyboardDismissRuntimeOperations { + return bindKeyboardDismiss(params.signal, localInteractorSource(params)); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderKeyboardDismissInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderKeyboardInteractorResolver; + }>, +): KeyboardDismissRuntimeOperations { + return bindKeyboardDismiss( + params.signal, + providerInteractorSource({ ...params, operation: 'keyboard dismiss' }), + ); +} + +export function bindLocalKeyboardEnterInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalKeyboardInteractorResolver; + }>, +): KeyboardEnterRuntimeOperations { + return bindKeyboardEnter(params.signal, localInteractorSource(params)); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderKeyboardEnterInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderKeyboardInteractorResolver; + }>, +): KeyboardEnterRuntimeOperations { + return bindKeyboardEnter( + params.signal, + providerInteractorSource({ ...params, operation: 'keyboard enter' }), + ); +} diff --git a/packages/contracts/src/orientation-runtime.test.ts b/packages/contracts/src/orientation-runtime.test.ts new file mode 100644 index 0000000000..1c1b6445ab --- /dev/null +++ b/packages/contracts/src/orientation-runtime.test.ts @@ -0,0 +1,93 @@ +import { expect, test, vi } from 'vitest'; +import { + bindLocalOrientationInteractor, + bindProviderOrientationInteractor, + orientationRuntimeOperationFacts, +} from './orientation-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact orientation operation fact catalog', () => { + const orientation = { available: true } as const; + expect(orientationRuntimeOperationFacts({ orientation })).toEqual({ + setOrientation: orientation, + }); +}); + +test('a local binding drives the interactor with the requested rotation and returns its report', async () => { + const setOrientation = vi.fn(async () => ({ orientation: 'landscape-left' as const })); + const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalOrientationInteractor({ device, signal, resolveInteractor }); + const result = await operations.setOrientation({ + rotation: 'landscape-left', + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'orientation-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'orientation-1', + appBundleId: 'com.example.app', + signal, + }); + expect(setOrientation).toHaveBeenCalledWith('landscape-left'); + expect(result).toEqual({ orientation: 'landscape-left' }); +}); + +test('a provider binding drives its own resolved interactor', async () => { + const setOrientation = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(() => ({ setOrientation }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindProviderOrientationInteractor({ device, signal, resolveInteractor }); + await operations.setOrientation({ + rotation: 'portrait', + execution: { requestId: 'orientation-2' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith({ + requestId: 'orientation-2', + appBundleId: undefined, + signal, + }); + expect(setOrientation).toHaveBeenCalledWith('portrait'); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderOrientationInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.setOrientation({ rotation: 'portrait' })).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const setOrientation = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); + + const operations = bindLocalOrientationInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.setOrientation({ rotation: 'portrait' })).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(setOrientation).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/orientation-runtime.ts b/packages/contracts/src/orientation-runtime.ts new file mode 100644 index 0000000000..601c17627a --- /dev/null +++ b/packages/contracts/src/orientation-runtime.ts @@ -0,0 +1,92 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { DeviceRotation } from './device-rotation.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one orientation change. `rotation` is already parsed by the caller + * (`parseDeviceRotation`); the operation names no command, request, session, or CLI flag. + */ +export type SetOrientationInput = Readonly<{ + rotation: DeviceRotation; + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * The interactor's own closed result: the rotation it actually reports, or nothing when the + * owner does not echo one back (the legacy leaf fell back to the requested rotation in that case). + */ +export type SetOrientationResult = Readonly<{ orientation?: DeviceRotation }>; + +export type OrientationRuntimeOperations = Readonly<{ + setOrientation(input: SetOrientationInput): Promise; +}>; + +export type OrientationRuntimeOperationFacts = Readonly<{ + setOrientation: RuntimeOperationFact; +}>; + +export function orientationRuntimeOperationFacts( + input: Readonly<{ orientation: RuntimeOperationFact }>, +): OrientationRuntimeOperationFacts { + return Object.freeze({ setOrientation: input.orientation }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the rotation itself. + */ +function bindOrientation( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): OrientationRuntimeOperations { + return Object.freeze({ + setOrientation: async (input: SetOrientationInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + return await interactor.setOrientation(input.rotation); + }, + }); +} + +export type LocalOrientationInteractorResolver = LocalInteractorOperationResolver; + +export function bindLocalOrientationInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalOrientationInteractorResolver; + }>, +): OrientationRuntimeOperations { + return bindOrientation(params.signal, localInteractorSource(params)); +} + +export type ProviderOrientationInteractorResolver = ProviderInteractorOperationResolver; + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderOrientationInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderOrientationInteractorResolver; + }>, +): OrientationRuntimeOperations { + return bindOrientation( + params.signal, + providerInteractorSource({ ...params, operation: 'orientation' }), + ); +} diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 2c93a6181d..1941cadb84 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -19,6 +19,11 @@ import type { ViewportRuntimeOperations } from './viewport-runtime.ts'; import type { FocusRuntimeOperations } from './focus-runtime.ts'; import type { TypeTextRuntimeOperations } from './type-text-runtime.ts'; import type { ElementTextRuntimeOperations } from './element-text-runtime.ts'; +import type { BackRuntimeOperations } from './back-runtime.ts'; +import type { HomeRuntimeOperations } from './home-runtime.ts'; +import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; +import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; +import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { DeviceReadinessRuntimeHost, DeviceReadinessRuntimeOperations, @@ -55,6 +60,11 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & FocusRuntimeOperations & TypeTextRuntimeOperations & ElementTextRuntimeOperations & + BackRuntimeOperations & + HomeRuntimeOperations & + OrientationRuntimeOperations & + TvRemoteRuntimeOperations & + KeyboardRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & ApplicationLifecycleRuntimeOperations; @@ -76,6 +86,13 @@ export const captureSnapshotUse = defineUse({ required: ['captureSnapshot'] }); export const viewportRuntimeUse = defineUse({ required: ['setViewport'] }); export const focusRuntimeUse = defineUse({ required: ['focusPoint'] }); export const typeTextRuntimeUse = defineUse({ required: ['typeText'] }); +export const backRuntimeUse = defineUse({ required: ['back'] }); +export const homeRuntimeUse = defineUse({ required: ['home'] }); +export const orientationRuntimeUse = defineUse({ required: ['setOrientation'] }); +export const tvRemoteRuntimeUse = defineUse({ required: ['tvRemote'] }); +export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); +export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); +export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); const captureSnapshotWithCustomActionsUse = defineUse({ required: ['captureSnapshot', 'captureSnapshotWithCustomActions'], }); @@ -398,6 +415,19 @@ export const appStateRuntimeUses = Object.freeze([appStateUse] as const); export const shutdownTargetUse = defineUse({ required: ['shutdownTarget'] }); +/** + * `keyboard`'s action-selected uses (ADR 0019 §9: one bind per handler). `status` is Android-only + * (parity with the retired leaf's per-family rejection of `status`/`get` everywhere else); + * `dismiss` and `enter` are the cross-platform legs. The daemon's `keyboard-runtime.ts` admits + * and binds exactly one of the three exported uses per request, keyed by the parsed action — + * never all three. + */ +export const keyboardRuntimePlanUses = Object.freeze([ + keyboardStatusUse, + keyboardDismissUse, + keyboardEnterUse, +] as const); + export type PlatformRuntimeHost = AppLogRuntimeHost & NetworkRuntimeHost & Readonly<{ diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 9bf2abdb79..9ed945a90d 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -34,6 +34,13 @@ test('generic unavailable binding preserves exact provider ownership and mode', focus: { available: false, reason: 'unsupported-provider-mode' }, typeText: { available: false, reason: 'unsupported-provider-mode' }, elementText: { available: false, reason: 'unsupported-provider-mode' }, + back: { available: false, reason: 'unsupported-provider-mode' }, + 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' }, lifecycle, }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 55c94a8073..ee7d85cb08 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -17,6 +17,11 @@ import { viewportRuntimeOperationFacts } from './viewport-runtime.ts'; import { focusRuntimeOperationFacts } from './focus-runtime.ts'; import { typeTextRuntimeOperationFacts } from './type-text-runtime.ts'; import { elementTextRuntimeOperationFacts } from './element-text-runtime.ts'; +import { backRuntimeOperationFacts } from './back-runtime.ts'; +import { homeRuntimeOperationFacts } from './home-runtime.ts'; +import { orientationRuntimeOperationFacts } from './orientation-runtime.ts'; +import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; +import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; /** * A runtime-contract helper for provider ownership gaps. It never assigns lifecycle semantics: @@ -35,6 +40,13 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ focus: RuntimeOperationUnavailability; typeText: RuntimeOperationUnavailability; elementText: RuntimeOperationUnavailability; + back: RuntimeOperationUnavailability; + home: RuntimeOperationUnavailability; + orientation: RuntimeOperationUnavailability; + tvRemote: RuntimeOperationUnavailability; + keyboardStatus: RuntimeOperationUnavailability; + keyboardDismiss: RuntimeOperationUnavailability; + keyboardEnter: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -81,6 +93,13 @@ export function createUnavailablePlatformRuntimeFacts( focus, typeText, elementText, + back, + home, + orientation, + tvRemote, + keyboardStatus, + keyboardDismiss, + keyboardEnter, readiness, shutdown, lifecycle, @@ -123,6 +142,15 @@ export function createUnavailablePlatformRuntimeFacts( ...focusRuntimeOperationFacts({ focus }), ...typeTextRuntimeOperationFacts({ type: typeText }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementText }), + ...backRuntimeOperationFacts({ back }), + ...homeRuntimeOperationFacts({ home }), + ...orientationRuntimeOperationFacts({ orientation }), + ...tvRemoteRuntimeOperationFacts({ tvRemote }), + ...keyboardRuntimeOperationFacts({ + status: keyboardStatus, + dismiss: keyboardDismiss, + enter: keyboardEnter, + }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -157,6 +185,16 @@ function freezeUnavailableFacts( readiness: orNetwork(unavailable.readiness), shutdown: orNetwork(unavailable.shutdown), elementText: Object.freeze({ ...unavailable.elementText }), + // Navigation and keyboard cells are stated by their owner too: each differs by family + // (harmonyos drives back/home but not orientation/tvRemote; android alone answers a keyboard + // status read), so none of them may inherit a sibling's gap. + back: Object.freeze({ ...unavailable.back }), + home: Object.freeze({ ...unavailable.home }), + orientation: Object.freeze({ ...unavailable.orientation }), + tvRemote: Object.freeze({ ...unavailable.tvRemote }), + keyboardStatus: Object.freeze({ ...unavailable.keyboardStatus }), + keyboardDismiss: Object.freeze({ ...unavailable.keyboardDismiss }), + keyboardEnter: Object.freeze({ ...unavailable.keyboardEnter }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), }); } diff --git a/packages/contracts/src/tv-remote-runtime.test.ts b/packages/contracts/src/tv-remote-runtime.test.ts new file mode 100644 index 0000000000..367b290134 --- /dev/null +++ b/packages/contracts/src/tv-remote-runtime.test.ts @@ -0,0 +1,90 @@ +import { expect, test, vi } from 'vitest'; +import { + bindLocalTvRemoteInteractor, + bindProviderTvRemoteInteractor, + tvRemoteRuntimeOperationFacts, +} from './tv-remote-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'vega', + id: 'vega-vvd', + name: 'Vega VVD', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact tv-remote operation fact catalog', () => { + const tvRemote = { available: true } as const; + expect(tvRemoteRuntimeOperationFacts({ tvRemote })).toEqual({ tvRemote }); +}); + +test('a local binding drives the interactor with the button and duration', async () => { + const tvRemote = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalTvRemoteInteractor({ device, signal, resolveInteractor }); + await operations.tvRemote({ + button: 'down', + durationMs: 250, + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'tv-remote-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'tv-remote-1', + appBundleId: 'com.example.app', + signal, + }); + // Positional (button, durationMs), not an object: swapping the argument order or dropping + // durationMs is the one transposition a point-shaped assertion would not catch. + expect(tvRemote).toHaveBeenCalledWith('down', 250); +}); + +test('a provider binding drives its own resolved interactor', async () => { + const tvRemote = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(() => ({ tvRemote }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindProviderTvRemoteInteractor({ device, signal, resolveInteractor }); + await operations.tvRemote({ button: 'select', execution: { requestId: 'tv-remote-2' } }); + + expect(resolveInteractor).toHaveBeenCalledWith({ + requestId: 'tv-remote-2', + appBundleId: undefined, + signal, + }); + expect(tvRemote).toHaveBeenCalledWith('select', undefined); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderTvRemoteInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.tvRemote({ button: 'down' })).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const tvRemote = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); + + const operations = bindLocalTvRemoteInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.tvRemote({ button: 'down' })).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(tvRemote).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/tv-remote-runtime.ts b/packages/contracts/src/tv-remote-runtime.ts new file mode 100644 index 0000000000..8112bdd75e --- /dev/null +++ b/packages/contracts/src/tv-remote-runtime.ts @@ -0,0 +1,93 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { TvRemoteButton } from './tv-remote.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one TV remote button press. `button` and `durationMs` are already parsed + * and range-validated by the caller; the operation names no command, request, session, or CLI + * flag. + */ +export type TvRemoteInput = Readonly<{ + button: TvRemoteButton; + durationMs?: number; + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * TV remote returns nothing. The legacy leaf discarded whatever the interactor answered and + * reported only the button it pressed, so a result type here would be a surface the command + * never had. + */ +export type TvRemoteRuntimeOperations = Readonly<{ + tvRemote(input: TvRemoteInput): Promise; +}>; + +export type TvRemoteRuntimeOperationFacts = Readonly<{ + tvRemote: RuntimeOperationFact; +}>; + +export function tvRemoteRuntimeOperationFacts( + input: Readonly<{ tvRemote: RuntimeOperationFact }>, +): TvRemoteRuntimeOperationFacts { + return Object.freeze({ tvRemote: input.tvRemote }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the button press itself. + */ +function bindTvRemote( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): TvRemoteRuntimeOperations { + return Object.freeze({ + tvRemote: async (input: TvRemoteInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + await interactor.tvRemote(input.button, input.durationMs); + }, + }); +} + +export type LocalTvRemoteInteractorResolver = LocalInteractorOperationResolver; + +export function bindLocalTvRemoteInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalTvRemoteInteractorResolver; + }>, +): TvRemoteRuntimeOperations { + return bindTvRemote(params.signal, localInteractorSource(params)); +} + +export type ProviderTvRemoteInteractorResolver = ProviderInteractorOperationResolver; + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderTvRemoteInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderTvRemoteInteractorResolver; + }>, +): TvRemoteRuntimeOperations { + return bindTvRemote( + params.signal, + providerInteractorSource({ ...params, operation: 'tv-remote' }), + ); +} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 6fc3f5c786..7ff00f2fec 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -190,6 +190,127 @@ test('rejects the non-discovered Android simulator cell for appstate', async () expect(binding.facts.operations.appState).toEqual(appStateUnavailable); expect(binding.operations.appState).toBeUndefined(); }); + +function androidNavigationHostFixture() { + return { + processTransports: { resolve: async () => ({ mode: 'local' as const }) }, + appInventory: { + apple: { listApps: async () => [] }, + android: { listApps: async () => [] }, + harmonyos: { listApps: async () => [] }, + }, + appState: { + android: { run: async () => ({ stdout: '' }) }, + harmonyos: { run: async () => ({ stdout: '' }) }, + }, + deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } }, + localInteractors: { resolve: async () => ({}) }, + screenRecording: { + android: { + resolve: async () => ({ + mode: 'local' as const, + start: async () => { + throw new Error('unused'); + }, + signal: async () => true, + isRunning: async () => false, + exists: async () => false, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + readManifest: async () => undefined, + writeManifest: async () => {}, + removeManifest: async () => {}, + }), + }, + }, + } as unknown as PlatformRuntimeHost; +} + +test.each([ + ['emulator', device], + ['device', { ...device, kind: 'device' as const }], + ['unknown', unknownKindDevice], +])( + 'classifies Android %s back/home/orientation/keyboard facts through the shared touch gate', + async (_name, runtimeDevice) => { + const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ + device: runtimeDevice, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const { facts } = binding; + + // back/home/orientation/keyboard status+dismiss+enter all ride the same adb-driven touch + // gate as focus/type: available for every real Android kind, parity with the retired buckets. + for (const operation of [ + 'back', + 'home', + 'setOrientation', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { + expect(facts.operations[operation]).toEqual({ available: true }); + expect(binding.operations[operation]).toBeTypeOf('function'); + } + + // tv-remote additionally requires a real TV target; none of these rows carry one. + expect(facts.operations.tvRemote).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'tv-remote is supported only on Android TV targets.', + }); + expect(binding.operations.tvRemote).toBeUndefined(); + }, +); + +test('admits Android tv-remote only for a real TV target', async () => { + const tvDevice = { ...device, kind: 'emulator' as const, target: 'tv' as const }; + const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ + device: tvDevice, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + expect(binding.facts.operations.tvRemote).toEqual({ available: true }); + expect(binding.operations.tvRemote).toBeTypeOf('function'); +}); + +test('the synthetic Android simulator cell refuses back/home/orientation/keyboard like every other touch operation', async () => { + const simulatorDevice = { ...device, id: 'android-simulator', kind: 'simulator' as const }; + const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ + device: simulatorDevice, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const { facts } = binding; + + for (const operation of [ + 'back', + 'home', + 'setOrientation', + 'tvRemote', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { + expect(facts.operations[operation].available).toBe(false); + expect(binding.operations[operation]).toBeUndefined(); + } +}); + type LegacyLifecycleCell = Readonly<{ openTarget: boolean; prepareAppleRunner: boolean; diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 1f326a0fae..e351ed00fa 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -5,6 +5,8 @@ import type { PlatformRuntimeOperations, PlatformRuntimeOwner, EnsureReadyInput, + RuntimeFacts, + RuntimeOperationFact, } from '@agent-device/contracts/platform'; import { applicationLifecycleOperationFacts, @@ -22,6 +24,18 @@ import { selectorObservationRuntimeOperationFacts, snapshotRuntimeOperationFacts, viewportRuntimeOperationFacts, + bindLocalBackInteractor, + backRuntimeOperationFacts, + bindLocalHomeInteractor, + homeRuntimeOperationFacts, + bindLocalOrientationInteractor, + orientationRuntimeOperationFacts, + bindLocalTvRemoteInteractor, + tvRemoteRuntimeOperationFacts, + bindLocalKeyboardStatusInteractor, + bindLocalKeyboardDismissInteractor, + bindLocalKeyboardEnterInteractor, + keyboardRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createAndroidAppLogRuntime } from './logs/runtime.ts'; @@ -149,6 +163,52 @@ function androidTouchFact(device: DeviceInfo) { return device.kind === 'simulator' ? focusKindUnavailable : available; } +const tvRemoteUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'tv-remote is supported only on Android TV targets.', +} as const); +/** + * Parity with the retired `androidPlugin` closure: the TV-target gate, whose hint fired + * regardless of device kind (the closure never distinguished the synthetic `simulator` row from + * a real non-TV device), so both refuse with the identical hint text. + */ +function androidTvRemoteFact(device: DeviceInfo): RuntimeOperationFact { + return device.kind !== 'simulator' && device.target === 'tv' ? available : tvRemoteUnavailable; +} + +/** + * The seven navigation/keyboard operations, each independently gated by its own admitted fact. + * HarmonyOS's runtime binds a near-identical shape (its own operation subset), but ADR 0019 + * forbids a `platform-common` package for two implementations to share — each family owns its + * own copy rather than tunnel through root or a sibling platform package. + */ +function androidNavigationAndKeyboardOperations( + // fallow-ignore-next-line code-duplication + host: PlatformRuntimeHost, + request: Parameters[0], + facts: RuntimeFacts, +): Partial['operations']> { + const resolver = { + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }; + return { + ...(facts.operations.back.available ? bindLocalBackInteractor(resolver) : {}), + ...(facts.operations.home.available ? bindLocalHomeInteractor(resolver) : {}), + ...(facts.operations.setOrientation.available ? bindLocalOrientationInteractor(resolver) : {}), + ...(facts.operations.tvRemote.available ? bindLocalTvRemoteInteractor(resolver) : {}), + ...(facts.operations.keyboardStatus.available + ? bindLocalKeyboardStatusInteractor(resolver) + : {}), + ...(facts.operations.keyboardDismiss.available + ? bindLocalKeyboardDismissInteractor(resolver) + : {}), + ...(facts.operations.keyboardEnter.available ? bindLocalKeyboardEnterInteractor(resolver) : {}), + }; +} + export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAndroidAppLogRuntime(host); const inspectFacts = async (device: Parameters[0]) => { @@ -187,6 +247,17 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ...elementTextRuntimeOperationFacts({ readTextAtPoint: device.kind === 'simulator' ? elementTextKindUnavailable : available, }), + ...backRuntimeOperationFacts({ back: androidTouchFact(device) }), + ...homeRuntimeOperationFacts({ home: androidTouchFact(device) }), + ...orientationRuntimeOperationFacts({ orientation: androidTouchFact(device) }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: androidTvRemoteFact(device) }), + // 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({ + status: androidTouchFact(device), + dismiss: androidTouchFact(device), + enter: androidTouchFact(device), + }), ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, @@ -268,6 +339,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor resolveInteractor: host.localInteractors.resolve, }) : {}), + ...androidNavigationAndKeyboardOperations(host, request, facts), ensureReady: async (input: EnsureReadyInput) => await ensureAndroidReady( host, diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index b366642f0b..9203f0ce6c 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -153,6 +153,70 @@ function expectAppleSnapshotAvailability( ); } +test.each(Object.entries(leaves))( + 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + async (_name, device) => { + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const { facts } = binding; + + // Every simulator/device leaf supports back, tvOS's Menu remote press included — no + // apple-family closure ever gated it beyond device kind. + expect(facts.operations.back).toEqual({ available: true }); + expect(binding.operations.back).toBeTypeOf('function'); + + // home is unavailable only on macOS, which drives an already-running app with no springboard. + const homeAvailable = device.appleOs !== 'macos'; + expect(facts.operations.home.available).toBe(homeAvailable); + expect(binding.operations.home).toBeTypeOf(homeAvailable ? 'function' : 'undefined'); + + // orientation and keyboard dismiss/enter share mobile-input eligibility: unavailable on tvOS + // (focus-only XCUIRemote navigation) and macOS (an AppKit desktop host). + const mobileInputEligible = device.appleOs !== 'tvos' && device.appleOs !== 'macos'; + expect(facts.operations.setOrientation.available).toBe(mobileInputEligible); + expect(binding.operations.setOrientation).toBeTypeOf( + mobileInputEligible ? 'function' : 'undefined', + ); + expect(facts.operations.keyboardDismiss.available).toBe(mobileInputEligible); + expect(facts.operations.keyboardEnter.available).toBe(mobileInputEligible); + expect(binding.operations.keyboardDismiss).toBeTypeOf( + mobileInputEligible ? 'function' : 'undefined', + ); + expect(binding.operations.keyboardEnter).toBeTypeOf( + mobileInputEligible ? 'function' : 'undefined', + ); + + // Apple never had a live keyboard status read: every eligible leaf still refuses status/get + // with the retired in-handler hint; ineligible leaves fall through the outer cell instead. + expect(facts.operations.keyboardStatus.available).toBe(false); + if (mobileInputEligible) { + expect(facts.operations.keyboardStatus).toHaveProperty( + 'hint', + expect.stringContaining('keyboard status/get is currently supported only on Android'), + ); + } + expect(binding.operations.keyboardStatus).toBeUndefined(); + + // tv-remote is available only for tvOS, which drives navigation through XCUIRemote presses. + const tvRemoteAvailable = device.appleOs === 'tvos'; + expect(facts.operations.tvRemote.available).toBe(tvRemoteAvailable); + if (!tvRemoteAvailable) { + expect(facts.operations.tvRemote).toHaveProperty( + 'hint', + 'tv-remote is supported only on tvOS devices.', + ); + } + expect(binding.operations.tvRemote).toBeTypeOf(tvRemoteAvailable ? 'function' : 'undefined'); + }, +); + test.each(['frontmost-app', 'desktop', 'menubar'] as const)( 'routes the macOS %s surface through the exact Apple surface host', async (surface) => { diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 47f71cbcd5..b6974867b1 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -21,10 +21,22 @@ import { selectorObservationRuntimeOperationFacts, snapshotRuntimeOperationFacts, viewportRuntimeOperationFacts, + bindLocalBackInteractor, + backRuntimeOperationFacts, + bindLocalHomeInteractor, + homeRuntimeOperationFacts, + bindLocalOrientationInteractor, + orientationRuntimeOperationFacts, + bindLocalTvRemoteInteractor, + tvRemoteRuntimeOperationFacts, + bindLocalKeyboardDismissInteractor, + bindLocalKeyboardEnterInteractor, + keyboardRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import { isIosFamily, isMacOs, + isTvOsDevice, resolveDeviceAppleOs, type DeviceInfo, } from '@agent-device/kernel/device'; @@ -231,6 +243,93 @@ function appleFocusFact(device: DeviceInfo): RuntimeOperationFact { return device.kind === 'simulator' || device.kind === 'device' ? available : focusKindUnavailable; } +const backKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'back is supported on Apple simulators and physical devices.', +} as const); +/** No apple-family closure ever gated `back` beyond device kind: every Apple OS, tvOS included + * (the interactor drives the remote's Menu button there), supports it. */ +function appleBackFact(device: DeviceInfo): RuntimeOperationFact { + return device.kind === 'simulator' || device.kind === 'device' ? available : backKindUnavailable; +} + +const homeKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'home is supported on Apple simulators and physical devices.', +} as const); +/** Parity with the retired `supportsAppAndDeviceLifecycle` closure: unavailable only on macOS, + * which drives an already-running app with no springboard home. */ +const homeLifecycleUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); +function appleHomeFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return homeKindUnavailable; + return isMacOs(device) ? homeLifecycleUnavailable : available; +} + +/** + * The per-AppleOS mobile-input eligibility `orientation` and `keyboard` (dismiss/enter) share: + * unavailable only on tvOS (focus-only XCUIRemote navigation, no orientation or keyboard) and + * macOS (an AppKit desktop host, no device orientation or software keyboard). Parity with the + * retired `supportsOrientation`/`supportsKeyboard` closures, which read the same per-OS table. + */ +function appleMobileInputEligible(device: DeviceInfo): boolean { + if (device.kind !== 'simulator' && device.kind !== 'device') return false; + const os = resolveDeviceAppleOs(device); + return os !== 'tvos' && os !== 'macos'; +} + +const orientationKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'orientation is supported on Apple simulators and physical devices.', +} as const); +const orientationOsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); +function appleOrientationFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return orientationKindUnavailable; + return appleMobileInputEligible(device) ? available : orientationOsUnavailable; +} + +const tvRemoteUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'tv-remote is supported only on tvOS devices.', +} as const); +function appleTvRemoteFact(device: DeviceInfo): RuntimeOperationFact { + return (device.kind === 'simulator' || device.kind === 'device') && isTvOsDevice(device) + ? available + : 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). */ +const keyboardCellUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); +const keyboardStatusUnsupported = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'keyboard status/get is currently supported only on Android; use keyboard dismiss or enter on iOS', +} as const); +/** Apple never had a live keyboard status read: every eligible cell still refuses `status`/`get` + * with the retired in-handler hint. */ +function appleKeyboardStatusFact(device: DeviceInfo): RuntimeOperationFact { + return appleMobileInputEligible(device) ? keyboardStatusUnsupported : keyboardCellUnavailable; +} +function appleKeyboardDismissFact(device: DeviceInfo): RuntimeOperationFact { + return appleMobileInputEligible(device) ? available : keyboardCellUnavailable; +} +function appleKeyboardEnterFact(device: DeviceInfo): RuntimeOperationFact { + return appleMobileInputEligible(device) ? available : keyboardCellUnavailable; +} + export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAppleAppLogRuntime(host); const inspectFacts = async (device: DeviceInfo) => { @@ -273,6 +372,15 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR // exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`). ...typeTextRuntimeOperationFacts({ type: appleFocusFact(device) }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), + ...backRuntimeOperationFacts({ back: appleBackFact(device) }), + ...homeRuntimeOperationFacts({ home: appleHomeFact(device) }), + ...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }), + ...keyboardRuntimeOperationFacts({ + status: appleKeyboardStatusFact(device), + dismiss: appleKeyboardDismissFact(device), + enter: appleKeyboardEnterFact(device), + }), ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, @@ -290,102 +398,155 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR const logs = await appLogs.bind(request); const facts = await inspectFacts(request.device); const recordingFacts = facts.operations.screenRecordingStart; - return Object.freeze({ - device: logs.device, - owner, - facts, - operations: Object.freeze({ - ...logs.operations, - ...createAppleAppDeploymentOperations({ + const captureOperations: Partial['operations']> = { + ...logs.operations, + ...createAppleAppDeploymentOperations({ + host, + device: request.device, + signal: request.scope.signal, + }), + networkDump: async (input: NetworkDumpInput) => + await dumpAppleNetworkTraffic(host, request.device, input, request.scope.signal), + ...whenAdmitted(recordingFacts, () => + createAppleScreenRecordingOperations({ host, device: request.device, + owner, signal: request.scope.signal, }), - networkDump: async (input: NetworkDumpInput) => - await dumpAppleNetworkTraffic(host, request.device, input, request.scope.signal), - ...whenAdmitted(recordingFacts, () => - createAppleScreenRecordingOperations({ - host, - device: request.device, - owner, - signal: request.scope.signal, - }), - ), - ...whenAdmitted(facts.operations.captureSnapshot, () => - bindAppleSnapshotRuntime(host, { - device: request.device, - signal: request.scope.signal, - }), - ), - ...whenAdmitted(facts.operations.captureScreenshot, () => - bindLocalScreenshotInteractor({ + ), + ...whenAdmitted(facts.operations.captureSnapshot, () => + bindAppleSnapshotRuntime(host, { + device: request.device, + signal: request.scope.signal, + }), + ), + ...whenAdmitted(facts.operations.captureScreenshot, () => + bindLocalScreenshotInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }), + ), + ...whenAdmitted(facts.operations.focusPoint, () => + bindLocalFocusInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }), + ), + ...whenAdmitted(facts.operations.typeText, () => + bindLocalTypeTextInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }), + ), + ...whenAdmitted(facts.operations.readTextAtPoint, () => + bindElementTextRuntime({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }), + ), + ...whenAdmitted(facts.operations.findText, () => + bindAppleFindTextRuntime(host, { + device: request.device, + signal: request.scope.signal, + }), + ), + ...whenAdmitted(facts.operations.findSelector, () => + bindAppleFindSelectorRuntime(host, { + device: request.device, + signal: request.scope.signal, + }), + ), + }; + const navigationOperations: Partial['operations']> = + { + ...whenAdmitted(facts.operations.back, () => + bindLocalBackInteractor({ device: request.device, signal: request.scope.signal, resolveInteractor: host.localInteractors.resolve, }), ), - ...whenAdmitted(facts.operations.focusPoint, () => - bindLocalFocusInteractor({ + ...whenAdmitted(facts.operations.home, () => + bindLocalHomeInteractor({ device: request.device, signal: request.scope.signal, resolveInteractor: host.localInteractors.resolve, }), ), - ...whenAdmitted(facts.operations.typeText, () => - bindLocalTypeTextInteractor({ + ...whenAdmitted(facts.operations.setOrientation, () => + bindLocalOrientationInteractor({ device: request.device, signal: request.scope.signal, resolveInteractor: host.localInteractors.resolve, }), ), - ...whenAdmitted(facts.operations.readTextAtPoint, () => - bindElementTextRuntime({ + ...whenAdmitted(facts.operations.tvRemote, () => + bindLocalTvRemoteInteractor({ device: request.device, signal: request.scope.signal, resolveInteractor: host.localInteractors.resolve, }), ), - ...whenAdmitted(facts.operations.findText, () => - bindAppleFindTextRuntime(host, { - device: request.device, - signal: request.scope.signal, - }), - ), - ...whenAdmitted(facts.operations.findSelector, () => - bindAppleFindSelectorRuntime(host, { + ...whenAdmitted(facts.operations.keyboardDismiss, () => + bindLocalKeyboardDismissInteractor({ device: request.device, signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, }), ), - ...whenAdmitted(facts.operations.ensureReady, () => ({ - ensureReady: async () => - await ensureAppleReady(host, request.device, request.scope.signal), - })), - ...whenAdmitted(facts.operations.bootTarget, () => ({ - bootTarget: async () => - await ensureAppleReady(host, request.device, request.scope.signal), - })), - ...whenAdmitted(facts.operations.listApps, () => ({ - listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => - await host.appInventory.apple.listApps( - input.device, - input.filter, - request.scope.signal, - ), - })), - ...availableApplicationLifecycleOperations( - bindAppleApplicationLifecycle({ - host, + ...whenAdmitted(facts.operations.keyboardEnter, () => + bindLocalKeyboardEnterInteractor({ device: request.device, signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, }), - facts.operations, ), - ...whenAdmitted(facts.operations.shutdownTarget, () => ({ - shutdownTarget: async () => - await host.deviceShutdown.apple.shutdownTarget(request.device, request.scope.signal), - })), - }), + }; + const lifecycleOperations: Partial['operations']> = { + ...whenAdmitted(facts.operations.ensureReady, () => ({ + ensureReady: async () => + await ensureAppleReady(host, request.device, request.scope.signal), + })), + ...whenAdmitted(facts.operations.bootTarget, () => ({ + bootTarget: async () => + await ensureAppleReady(host, request.device, request.scope.signal), + })), + ...whenAdmitted(facts.operations.listApps, () => ({ + listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => + await host.appInventory.apple.listApps( + input.device, + input.filter, + request.scope.signal, + ), + })), + ...availableApplicationLifecycleOperations( + bindAppleApplicationLifecycle({ + host, + device: request.device, + signal: request.scope.signal, + }), + facts.operations, + ), + ...whenAdmitted(facts.operations.shutdownTarget, () => ({ + shutdownTarget: async () => + await host.deviceShutdown.apple.shutdownTarget(request.device, request.scope.signal), + })), + }; + const operations: DeviceBinding['operations'] = { + ...captureOperations, + ...navigationOperations, + ...lifecycleOperations, + }; + return Object.freeze({ + device: logs.device, + owner, + facts, + operations: Object.freeze(operations), [Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](), }) satisfies DeviceBinding; }, diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 10d43198a1..5cdfd05bb2 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -81,6 +81,30 @@ test.each([ expect(facts.operations.typeText).toEqual({ available: true }); expect(binding.operations.focusPoint).toBeTypeOf('function'); expect(binding.operations.typeText).toBeTypeOf('function'); + // back/home/keyboard dismiss+enter share focus's hdc-driven gate on both real kinds. + for (const operation of ['back', 'home', 'keyboardDismiss', 'keyboardEnter'] as const) { + expect(facts.operations[operation]).toEqual({ available: true }); + expect(binding.operations[operation]).toBeTypeOf('function'); + } + // orientation and tv-remote never carried a HarmonyOS capability bucket, so both stay + // unavailable unconditionally even though the interactor is technically callable. + expect(facts.operations.setOrientation).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(facts.operations.tvRemote).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations.setOrientation).toBeUndefined(); + expect(binding.operations.tvRemote).toBeUndefined(); + // Android's live IME status read has no HarmonyOS counterpart. + expect(facts.operations.keyboardStatus).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'keyboard status/get is not available through the public HarmonyOS HDC API; use keyboard dismiss or enter', + }); + expect(binding.operations.keyboardStatus).toBeUndefined(); await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true }); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), @@ -115,6 +139,21 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( expect(binding.facts.operations.appState).toEqual(appStateUnavailable); expect(binding.facts.operations.setViewport).toMatchObject({ available: false }); expect(binding.operations.appState).toBeUndefined(); + // The synthetic simulator cell has no device behind it, so the hdc-driven navigation/keyboard + // gate refuses the same way focus/type does; orientation, tv-remote, and keyboard status stay + // unavailable regardless of kind. + for (const operation of [ + 'back', + 'home', + 'setOrientation', + 'tvRemote', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { + expect(binding.facts.operations[operation].available).toBe(false); + expect(binding.operations[operation]).toBeUndefined(); + } }); type LegacyLifecycleCell = Readonly<{ diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 6b0b0a3fb4..19859d9123 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -3,6 +3,7 @@ import type { PlatformRuntimeHost, PlatformRuntimeOperations, PlatformRuntimeOwner, + RuntimeFacts, RuntimeOperationFact, } from '@agent-device/contracts/platform'; import { @@ -20,6 +21,15 @@ import { selectorObservationRuntimeOperationFacts, snapshotRuntimeOperationFacts, viewportRuntimeOperationFacts, + bindLocalBackInteractor, + backRuntimeOperationFacts, + bindLocalHomeInteractor, + homeRuntimeOperationFacts, + orientationRuntimeOperationFacts, + tvRemoteRuntimeOperationFacts, + bindLocalKeyboardDismissInteractor, + bindLocalKeyboardEnterInteractor, + keyboardRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createHarmonyAppLogRuntime } from './logs/runtime.ts'; @@ -134,6 +144,51 @@ function harmonyFocusFact(device: DeviceInfo): RuntimeOperationFact { return device.kind === 'emulator' || device.kind === 'device' ? available : focusKindUnavailable; } +/** + * `orientation` and `tv-remote` never carried a HarmonyOS capability bucket: the family is absent + * from `HARMONYOS_SUPPORTED_COMMANDS` for both, so both are unavailable unconditionally — even + * though the interactor's own `setOrientation` is technically callable, admission never reached it. + */ +const harmonyPlatformLeafUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +/** Android's live IME status read has no HarmonyOS counterpart (parity with the retired leaf, + * which rejected `status`/`get` on every non-Android family). */ +const harmonyKeyboardStatusUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'keyboard status/get is not available through the public HarmonyOS HDC API; use keyboard dismiss or enter', +} as const); + +/** + * The four navigation/keyboard operations HarmonyOS admits, each gated by its own fact. The + * Android runtime binds a near-identical shape (its own operation subset), but ADR 0019 forbids a + * `platform-common` package for two implementations to share — each family owns its own copy + * rather than tunnel through root or a sibling platform package. + */ +function harmonyNavigationAndKeyboardOperations( + // fallow-ignore-next-line code-duplication + host: PlatformRuntimeHost, + request: Parameters[0], + facts: RuntimeFacts, +): Partial['operations']> { + const resolver = { + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }; + return { + ...(facts.operations.back.available ? bindLocalBackInteractor(resolver) : {}), + ...(facts.operations.home.available ? bindLocalHomeInteractor(resolver) : {}), + ...(facts.operations.keyboardDismiss.available + ? bindLocalKeyboardDismissInteractor(resolver) + : {}), + ...(facts.operations.keyboardEnter.available ? bindLocalKeyboardEnterInteractor(resolver) : {}), + }; +} + export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createHarmonyAppLogRuntime(host); const inspectFacts = async (device: Parameters[0]) => { @@ -179,6 +234,15 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor // 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. ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + ...backRuntimeOperationFacts({ back: harmonyFocusFact(device) }), + ...homeRuntimeOperationFacts({ home: harmonyFocusFact(device) }), + ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), + ...keyboardRuntimeOperationFacts({ + status: harmonyKeyboardStatusUnavailable, + dismiss: harmonyFocusFact(device), + enter: harmonyFocusFact(device), + }), ensureReady: available, bootTarget: unavailable, bootTargetHeadless: unavailable, @@ -254,6 +318,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor resolveInteractor: host.localInteractors.resolve, }) : {}), + ...harmonyNavigationAndKeyboardOperations(host, request, facts), listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => await host.appInventory.harmonyos.listApps( input.device, diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 96938457c5..a0741c2b7b 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -119,6 +119,7 @@ test.each([ expect(binding.operations.typeText).toBeTypeOf( device.kind === 'device' ? 'function' : 'undefined', ); + expectLinuxNavigationAndKeyboardFacts(binding, device); expect(binding.facts.operations.captureScreenshot.available).toBe(device.kind === 'device'); expect(binding.operations.captureScreenshot).toBeTypeOf( device.kind === 'device' ? 'function' : 'undefined', @@ -173,6 +174,34 @@ function expectLifecycleFacts( } } +/** + * back/home parity with the retired capability bucket: the desktop is the only Linux cell with a + * target to drive. orientation/tv-remote/keyboard never carried a Linux bucket at all. + */ +function expectLinuxNavigationAndKeyboardFacts( + binding: DeviceBinding, + device: DeviceInfo, +): void { + const desktop = device.kind === 'device'; + expect(binding.facts.operations.back.available).toBe(desktop); + expect(binding.facts.operations.home.available).toBe(desktop); + expect(binding.operations.back).toBeTypeOf(desktop ? 'function' : 'undefined'); + expect(binding.operations.home).toBeTypeOf(desktop ? 'function' : 'undefined'); + for (const operation of [ + 'setOrientation', + 'tvRemote', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { + expect(binding.facts.operations[operation]).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations[operation]).toBeUndefined(); + } +} + // `linuxSnapshotOperations` is the other direct `captureSurface` caller, so the shared // `bindSnapshotInteractor` composition does not cover it either. Note it composes against // `request.scope.signal` rather than `request.signal` — the Linux owner takes its signal off the diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 01e6558b58..9e1f14b8e4 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -5,6 +5,7 @@ import type { PlatformRuntimeOperations, PlatformRuntimeOwner, RuntimeFacts, + RuntimeOperationFact, RuntimeOperationUnavailability, } from '@agent-device/contracts/platform'; import { @@ -23,6 +24,10 @@ import { sameRuntimeOwner, screenshotRuntimeOperationFacts, snapshotRuntimeOperationFacts, + bindLocalBackInteractor, + backRuntimeOperationFacts, + bindLocalHomeInteractor, + homeRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -72,6 +77,17 @@ const snapshotCustomActionsUnavailable = unavailableLinuxRuntimeFact( 'unsupported-platform-leaf', 'Re-run without --actions, or target an iOS simulator.', ); +const backKindUnavailable = unavailableLinuxRuntimeFact( + 'unsupported-device-kind', + 'back is supported only for the Linux desktop device.', +); +const homeKindUnavailable = unavailableLinuxRuntimeFact( + 'unsupported-device-kind', + 'home is supported only for the Linux desktop device.', +); +// `orientation`, `tv-remote`, and every keyboard action never carried a Linux capability bucket +// at all (the retired descriptors declared `linux: {}`), so they are unavailable unconditionally. +const linuxPlatformLeafUnavailable = unsupportedPlatformLeaf; export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { return Object.freeze({ owner: linuxOwner, @@ -102,34 +118,7 @@ export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformR ...(facts.operations.captureSnapshot.available ? linuxSnapshotOperations(host, request) : {}), - ...(facts.operations.captureScreenshot.available - ? bindLocalScreenshotInteractor({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - }) - : {}), - ...(facts.operations.focusPoint.available - ? bindLocalFocusInteractor({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - }) - : {}), - ...(facts.operations.typeText.available - ? bindLocalTypeTextInteractor({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - }) - : {}), - ...(facts.operations.readTextAtPoint.available - ? bindElementTextRuntime({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - }) - : {}), + ...linuxInteractionOperations(host, request, facts), }), [Symbol.asyncDispose]: async () => undefined, }) satisfies DeviceBinding; @@ -138,6 +127,29 @@ export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformR }); } +/** The five desktop-interactor operations, each independently gated by its own admitted fact. */ +function linuxInteractionOperations( + host: PlatformRuntimeHost, + request: Parameters[0], + facts: RuntimeFacts, +): Partial['operations']> { + const resolver = { + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }; + return { + ...(facts.operations.captureScreenshot.available + ? bindLocalScreenshotInteractor(resolver) + : {}), + ...(facts.operations.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}), + ...(facts.operations.typeText.available ? bindLocalTypeTextInteractor(resolver) : {}), + ...(facts.operations.readTextAtPoint.available ? bindElementTextRuntime(resolver) : {}), + ...(facts.operations.back.available ? bindLocalBackInteractor(resolver) : {}), + ...(facts.operations.home.available ? bindLocalHomeInteractor(resolver) : {}), + }; +} + function linuxFacts(device: DeviceInfo): RuntimeFacts { const openTarget = device.kind === 'device' ? supported : openTargetKindUnavailable; const closeTarget = device.kind === 'device' ? supported : closeTargetKindUnavailable; @@ -150,6 +162,13 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts focus: focusKindUnavailable, typeText: typeKindUnavailable, elementText: elementTextKindUnavailable, + back: backKindUnavailable, + home: homeKindUnavailable, + orientation: linuxPlatformLeafUnavailable, + tvRemote: linuxPlatformLeafUnavailable, + keyboardStatus: linuxPlatformLeafUnavailable, + keyboardDismiss: linuxPlatformLeafUnavailable, + keyboardEnter: linuxPlatformLeafUnavailable, readiness: unsupportedPlatformLeaf, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: openTarget, @@ -168,31 +187,39 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts operations: { ...unavailable.operations, ...snapshotRuntimeOperationFacts({ - capture: device.kind === 'device' ? supported : snapshotKindUnavailable, + capture: linuxDesktopFact(device, snapshotKindUnavailable), customActions: snapshotCustomActionsUnavailable, - withoutActiveApp: device.kind === 'device' ? supported : snapshotKindUnavailable, + withoutActiveApp: linuxDesktopFact(device, snapshotKindUnavailable), }), ...screenshotRuntimeOperationFacts({ - capture: device.kind === 'device' ? supported : screenshotKindUnavailable, + capture: linuxDesktopFact(device, screenshotKindUnavailable), }), // Parity with the retired `focus` capability bucket (`{ device: true }`): the desktop is // the only Linux cell with a pointer to drive. - ...focusRuntimeOperationFacts({ - focus: device.kind === 'device' ? supported : focusKindUnavailable, - }), + ...focusRuntimeOperationFacts({ focus: linuxDesktopFact(device, focusKindUnavailable) }), // Text entry shares focus's cell: ydotool drives both on the desktop device only. - ...typeTextRuntimeOperationFacts({ - type: device.kind === 'device' ? supported : typeKindUnavailable, - }), + ...typeTextRuntimeOperationFacts({ type: linuxDesktopFact(device, typeKindUnavailable) }), // The Linux read is value-first (AXValue/title/description) where the captured tree is // label-first, so the desktop row genuinely reads differently from its snapshot text. ...elementTextRuntimeOperationFacts({ - readTextAtPoint: device.kind === 'device' ? supported : elementTextKindUnavailable, + readTextAtPoint: linuxDesktopFact(device, elementTextKindUnavailable), }), + // Parity with the retired `back`/`home` capability bucket (`{ device: true }`): the desktop + // is the only Linux cell with a target to drive. + ...backRuntimeOperationFacts({ back: linuxDesktopFact(device, backKindUnavailable) }), + ...homeRuntimeOperationFacts({ home: linuxDesktopFact(device, homeKindUnavailable) }), }, }); } +/** Every desktop-interactor cell reads the same way: only the Linux `device` kind has one. */ +function linuxDesktopFact( + device: DeviceInfo, + whenUnavailable: RuntimeOperationUnavailability, +): RuntimeOperationFact { + return device.kind === 'device' ? supported : whenUnavailable; +} + function linuxSnapshotOperations( host: PlatformRuntimeHost, request: Parameters[0], diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index a58f27f189..c1c9060f51 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -157,6 +157,28 @@ test.each([ }); expect(binding.operations.captureScreenshot).toBeUndefined(); expect(binding.operations.setViewport).toBeUndefined(); + // Remote navigation is Vega's first available interaction surface: back/home/tv-remote share + // the same VVD-only gate the retired `vegaPlugin` closure applied to all three. Orientation + // and every keyboard action never carried a Vega capability bucket at all. + const supported = device.kind === 'emulator' && device.target === 'tv'; + for (const operation of ['back', 'home', 'tvRemote'] as const) { + expect(binding.facts.operations[operation].available).toBe(supported); + expect(binding.operations[operation]).toBeTypeOf(supported ? 'function' : 'undefined'); + } + expect(binding.facts.operations.setOrientation).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'orientation is not supported on Vega OS.', + }); + expect(binding.operations.setOrientation).toBeUndefined(); + for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) { + expect(binding.facts.operations[operation]).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'keyboard is not supported on Vega OS.', + }); + expect(binding.operations[operation]).toBeUndefined(); + } expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index c071e08f39..f491e68d8d 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -12,6 +12,12 @@ import { createUnavailablePlatformRuntimeFacts, localRuntimeOwner, sameRuntimeOwner, + bindLocalBackInteractor, + backRuntimeOperationFacts, + bindLocalHomeInteractor, + homeRuntimeOperationFacts, + bindLocalTvRemoteInteractor, + tvRemoteRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -65,9 +71,30 @@ export function createVegaPlatformRuntime(host: PlatformRuntimeHost): PlatformRu device: request.device, owner: vegaOwner, facts, - operations: Object.freeze( - availableApplicationLifecycleOperations(lifecycle, facts.operations), - ), + operations: Object.freeze({ + ...availableApplicationLifecycleOperations(lifecycle, facts.operations), + ...(facts.operations.back.available + ? bindLocalBackInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }) + : {}), + ...(facts.operations.home.available + ? bindLocalHomeInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }) + : {}), + ...(facts.operations.tvRemote.available + ? bindLocalTvRemoteInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }) + : {}), + }), [Symbol.asyncDispose]: async () => undefined, }) satisfies DeviceBinding; }, @@ -88,12 +115,35 @@ const typeUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'type is not supported on Vega OS: the Vega runtime exposes remote navigation only.', ); +// `orientation` and every keyboard action never carried a Vega capability bucket at all; `back`, +// `home`, and `tv-remote` did (the retired `vegaPlugin` closure), gated by the same VVD cell +// their lifecycle open/close already require. +const orientationUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'orientation is not supported on Vega OS.', +); +const keyboardUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'keyboard is not supported on Vega OS.', +); +const backUnavailable = vegaUnavailable( + 'unsupported-device-kind', + 'back currently supports only Vega Virtual Devices.', +); +const homeUnavailable = vegaUnavailable( + 'unsupported-device-kind', + 'home currently supports only Vega Virtual Devices.', +); +const tvRemoteUnavailable = vegaUnavailable( + 'unsupported-device-kind', + 'tv-remote currently supports only Vega Virtual Devices.', +); function vegaFacts(device: DeviceInfo): RuntimeFacts { const supported = device.kind === 'emulator' && device.target === 'tv'; const openTarget = supported ? lifecycleAvailable : openTargetUnavailable; const closeTarget = supported ? lifecycleAvailable : closeTargetUnavailable; - return createUnavailablePlatformRuntimeFacts(device, vegaOwner, { + const unavailable = createUnavailablePlatformRuntimeFacts(device, vegaOwner, { appLog: unsupportedPlatformLeaf, network: unsupportedPlatformLeaf, screenshot: screenshotUnavailable, @@ -103,6 +153,13 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts focus: focusUnavailable, typeText: typeUnavailable, elementText: unsupportedPlatformLeaf, + back: backUnavailable, + home: homeUnavailable, + orientation: orientationUnavailable, + tvRemote: tvRemoteUnavailable, + keyboardStatus: keyboardUnavailable, + keyboardDismiss: keyboardUnavailable, + keyboardEnter: keyboardUnavailable, readiness: unsupportedPlatformLeaf, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: openTarget, @@ -116,6 +173,19 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts configureProviderPortReverse: providerPortReverseUnavailable, }), }); + return Object.freeze({ + device: unavailable.device, + operations: { + ...unavailable.operations, + // Remote navigation is the Vega runtime's first available interaction surface: the + // VVD-only gate the retired `vegaPlugin` closure applied to all three. + ...backRuntimeOperationFacts({ back: supported ? lifecycleAvailable : backUnavailable }), + ...homeRuntimeOperationFacts({ home: supported ? lifecycleAvailable : homeUnavailable }), + ...tvRemoteRuntimeOperationFacts({ + tvRemote: supported ? lifecycleAvailable : tvRemoteUnavailable, + }), + }, + }); } function vegaUnavailable( diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 4d13241387..4833178605 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -158,6 +158,29 @@ test.each([ }, ); +test('back/home/orientation/tv-remote/keyboard never carried a web capability bucket', async () => { + const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ + device, + intent: { kind: 'ordinary' }, + scope: scope(), + }); + for (const operation of [ + 'back', + 'home', + 'setOrientation', + 'tvRemote', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { + expect(binding.facts.operations[operation]).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations[operation]).toBeUndefined(); + } +}); + test('binds viewport resizing through the local web interactor and honors cancellation', async () => { const setViewport = vi.fn(async () => undefined); const runtimeHost = { diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index c14e9086fc..c00c80313f 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -21,6 +21,11 @@ import { snapshotRuntimeOperationFacts, sameRuntimeOwner, viewportRuntimeOperationFacts, + backRuntimeOperationFacts, + homeRuntimeOperationFacts, + orientationRuntimeOperationFacts, + tvRemoteRuntimeOperationFacts, + keyboardRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -61,6 +66,13 @@ const appStateUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', } as const); +// `back`, `home`, `orientation`, `tv-remote`, and every keyboard action never carried a web +// capability bucket (the retired `WEB_SUPPORTED_COMMANDS` overlay never listed them), so all are +// unavailable unconditionally. +const navigationUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); const prepareUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -284,6 +296,15 @@ function webRuntimeFacts( // The web backend has no point-addressed read: `get` answers from the captured DOM tree, // which is what the legacy dispatch already did once its Apple-runner attempt failed. ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + ...backRuntimeOperationFacts({ back: navigationUnavailable }), + ...homeRuntimeOperationFacts({ home: navigationUnavailable }), + ...orientationRuntimeOperationFacts({ orientation: navigationUnavailable }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: navigationUnavailable }), + ...keyboardRuntimeOperationFacts({ + status: navigationUnavailable, + dismiss: navigationUnavailable, + enter: navigationUnavailable, + }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 7ec5747589..e64d781ad1 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -3,6 +3,8 @@ import { appsRuntimeUse, bootTargetUse, narrowDeviceBinding, + type DeviceBinding, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test, vi } from 'vitest'; @@ -296,6 +298,7 @@ test.each([ reason: 'unsupported-provider-mode', }); expect(binding.operations.readTextAtPoint).toBeUndefined(); + expectLimrunNavigationAndKeyboardFacts(binding, runtimeDevice); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), ).resolves.toEqual([]); @@ -364,3 +367,43 @@ test('fails closed for a stale Android identity before exposing facts or binding }); expect(getAppState).not.toHaveBeenCalled(); }); + +/** + * back/orientation are admitted on both direct-session platforms; home/tv-remote differ by + * platform (the Android leg reuses the local family's interactor factory, the iOS leg refuses + * both explicitly); keyboard status/dismiss/enter reuse that same Android-only interactor. + */ +function expectLimrunNavigationAndKeyboardFacts( + binding: DeviceBinding, + runtimeDevice: DeviceInfo, +): void { + const isAndroid = runtimeDevice.platform === 'android'; + expect(binding.facts.operations.back).toEqual({ available: true }); + expect(binding.operations.back).toBeTypeOf('function'); + expect(binding.facts.operations.setOrientation).toEqual({ available: true }); + expect(binding.operations.setOrientation).toBeTypeOf('function'); + expect(binding.facts.operations.home.available).toBe(isAndroid); + expect(binding.operations.home).toBeTypeOf(isAndroid ? 'function' : 'undefined'); + if (!isAndroid) { + expect(binding.facts.operations.home).toMatchObject({ + hint: 'Limrun iOS direct sessions do not expose home yet.', + }); + } + // tv-remote additionally requires a real TV target on the Android leg. + expect(binding.facts.operations.tvRemote.available).toBe(false); + expect(binding.operations.tvRemote).toBeUndefined(); + expect(binding.facts.operations.tvRemote).toMatchObject({ + hint: isAndroid + ? 'tv-remote is supported only on Android TV targets.' + : 'Limrun iOS direct sessions do not expose tv remote control.', + }); + for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) { + expect(binding.facts.operations[operation].available).toBe(isAndroid); + expect(binding.operations[operation]).toBeTypeOf(isAndroid ? 'function' : 'undefined'); + if (!isAndroid) { + expect(binding.facts.operations[operation]).toMatchObject({ + hint: 'Limrun iOS direct sessions do not expose keyboard actions.', + }); + } + } +} diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index e6a78c47df..8467623cd6 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -3,7 +3,9 @@ import type { AppsFilter, ProviderPortReverseOptions } from '@agent-device/contr import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; import { bindLimrunInteractionOperations, + bindLimrunNavigationOperations, limrunInteractionOperationFacts, + limrunNavigationOperationFacts, } from './interaction-operations.ts'; import { AppError } from '@agent-device/kernel/errors'; import { parseLimrunDeviceId } from './device.ts'; @@ -33,6 +35,10 @@ import { selectorObservationRuntimeOperationFacts, snapshotRuntimeOperationFacts, viewportRuntimeOperationFacts, + keyboardRuntimeOperationFacts, + bindProviderKeyboardStatusInteractor, + bindProviderKeyboardDismissInteractor, + bindProviderKeyboardEnterInteractor, } from '@agent-device/contracts/platform'; import { createLimrunAppLogEnvelope, @@ -126,6 +132,23 @@ const prepareUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Apple runner preparation is unavailable for Limrun-owned devices.', } as const); +/** + * The retired leaf never routed `keyboard` through provider resolution at all — it dispatched + * directly by device platform, bypassing the interactor/provider seam entirely. The Android leg + * genuinely carries it (the same `createAndroidInteractor` factory the local family binds, added + * in this unit); the iOS leg has no such reuse, so it stays honestly unavailable rather than + * guessing at untested provider behavior. + */ +const keyboardUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose keyboard actions.', +} as const); +const iosAppStateUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS appstate is session-owned; no sessionless provider foreground probe is exposed.', +} as const); const openTargetUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -208,6 +231,13 @@ export function createLimrunPlatformRuntimeOwner( focus: liveSessionUnavailable, typeText: liveSessionUnavailable, elementText: liveSessionUnavailable, + back: liveSessionUnavailable, + home: liveSessionUnavailable, + orientation: liveSessionUnavailable, + tvRemote: liveSessionUnavailable, + keyboardStatus: liveSessionUnavailable, + keyboardDismiss: liveSessionUnavailable, + keyboardEnter: liveSessionUnavailable, readiness: liveSessionUnavailable, shutdown: liveSessionUnavailable, lifecycle: limrunLifecycleFacts(device, false), @@ -369,6 +399,38 @@ function bindLimrunAppLogs( runtimeFacts.operations, ), ...bindLimrunInteractionOperations({ device, signal, getInteractor: options.getInteractor }), + ...bindLimrunNavigationOperations({ + device, + signal, + facts: { + back: runtimeFacts.operations.back, + home: runtimeFacts.operations.home, + setOrientation: runtimeFacts.operations.setOrientation, + tvRemote: runtimeFacts.operations.tvRemote, + }, + getInteractor: options.getInteractor, + }), + ...(runtimeFacts.operations.keyboardStatus.available + ? bindProviderKeyboardStatusInteractor({ + device, + signal, + resolveInteractor: (runner) => options.getInteractor(device, runner), + }) + : {}), + ...(runtimeFacts.operations.keyboardDismiss.available + ? bindProviderKeyboardDismissInteractor({ + device, + signal, + resolveInteractor: (runner) => options.getInteractor(device, runner), + }) + : {}), + ...(runtimeFacts.operations.keyboardEnter.available + ? bindProviderKeyboardEnterInteractor({ + device, + signal, + resolveInteractor: (runner) => options.getInteractor(device, runner), + }) + : {}), ...createLimrunAppDeploymentOperations( deploymentOptions(options), device, @@ -410,6 +472,10 @@ function facts( device: DeviceInfo, ): RuntimeFacts { const deployment = limrunAppDeploymentFacts(deploymentOptions(options), device); + const isAndroid = device.platform === 'android'; + const keyboardFact = isAndroid ? available : keyboardUnavailableIos; + const customSnapshotFact = + isIosFamily(device) && device.kind === 'simulator' ? available : customSnapshotUnavailable; return Object.freeze({ device: { family: device.platform, @@ -428,24 +494,14 @@ function facts( appLogReattach: available, appLogCleanup: available, ...deployment, - appState: - device.platform === 'android' - ? available - : { - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS appstate is session-owned; no sessionless provider foreground probe is exposed.', - }, + appState: isAndroid ? available : iosAppStateUnavailable, networkDump: available, screenRecordingStart: recordingUnavailable, screenRecordingReattach: recordingUnavailable, screenRecordingCleanup: recordingUnavailable, ...snapshotRuntimeOperationFacts({ capture: available, - customActions: - isIosFamily(device) && device.kind === 'simulator' - ? available - : customSnapshotUnavailable, + customActions: customSnapshotFact, withoutActiveApp: available, }), ...screenshotRuntimeOperationFacts({ capture: available }), @@ -460,6 +516,12 @@ function facts( // device always has one, so it is available wherever a capture is. ...limrunInteractionOperationFacts(), ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + ...limrunNavigationOperationFacts(device), + ...keyboardRuntimeOperationFacts({ + status: keyboardFact, + dismiss: keyboardFact, + enter: keyboardFact, + }), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -505,6 +567,12 @@ function recoveryFacts( }), ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), ...limrunInteractionOperationFacts(liveSessionUnavailable), + ...limrunNavigationOperationFacts(device, liveSessionUnavailable), + ...keyboardRuntimeOperationFacts({ + status: liveSessionUnavailable, + dismiss: liveSessionUnavailable, + enter: liveSessionUnavailable, + }), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts new file mode 100644 index 0000000000..dada1a234a --- /dev/null +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -0,0 +1,130 @@ +import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test } from 'vitest'; +import { + bindLimrunNavigationOperations, + limrunNavigationOperationFacts, +} from './interaction-operations.ts'; + +const iosDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'limrun-ios', + name: 'Limrun iOS', + kind: 'simulator', + target: 'mobile', + booted: true, +}; +const androidMobileDevice: DeviceInfo = { + platform: 'android', + id: 'limrun-android-mobile', + name: 'Limrun Android', + kind: 'emulator', + target: 'mobile', + booted: true, +}; +const androidTvDevice: DeviceInfo = { + ...androidMobileDevice, + id: 'limrun-android-tv', + target: 'tv', +}; + +const liveSessionUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'The Limrun provider session is no longer active for this device.', +} as const); + +test('the Android leg admits back/home/orientation and gates tv-remote on a real TV target', () => { + const mobile = limrunNavigationOperationFacts(androidMobileDevice); + expect(mobile.back).toEqual({ available: true }); + expect(mobile.home).toEqual({ available: true }); + expect(mobile.setOrientation).toEqual({ available: true }); + expect(mobile.tvRemote).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'tv-remote is supported only on Android TV targets.', + }); + + const tv = limrunNavigationOperationFacts(androidTvDevice); + expect(tv.tvRemote).toEqual({ available: true }); +}); + +test('the iOS leg admits back/orientation but explicitly refuses home and tv-remote', () => { + const facts = limrunNavigationOperationFacts(iosDevice); + expect(facts.back).toEqual({ available: true }); + expect(facts.setOrientation).toEqual({ available: true }); + expect(facts.home).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose home yet.', + }); + expect(facts.tvRemote).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose tv remote control.', + }); +}); + +test('a dead session closes all four navigation cells with the same reason regardless of platform', () => { + const facts = limrunNavigationOperationFacts(androidTvDevice, liveSessionUnavailable); + expect(facts.back).toEqual(liveSessionUnavailable); + expect(facts.home).toEqual(liveSessionUnavailable); + expect(facts.setOrientation).toEqual(liveSessionUnavailable); + expect(facts.tvRemote).toEqual(liveSessionUnavailable); +}); + +test('binds only the operations the facts admitted, driving the resolved interactor', async () => { + const calls: string[] = []; + const interactor = { + back: async (mode: string | undefined) => { + calls.push(`back:${mode ?? 'default'}`); + }, + home: async () => { + calls.push('home'); + }, + setOrientation: async (rotation: string) => { + calls.push(`setOrientation:${rotation}`); + return undefined; + }, + tvRemote: async () => { + calls.push('tvRemote'); + }, + } as unknown as Interactor; + const getInteractor = (_device: DeviceInfo, _runner?: RunnerContext) => interactor; + const facts = limrunNavigationOperationFacts(androidTvDevice); + + const operations = bindLimrunNavigationOperations({ + device: androidTvDevice, + signal: new AbortController().signal, + facts, + getInteractor, + }); + + expect(operations.back).toBeTypeOf('function'); + expect(operations.home).toBeTypeOf('function'); + expect(operations.setOrientation).toBeTypeOf('function'); + expect(operations.tvRemote).toBeTypeOf('function'); + + await operations.back?.({}); + await operations.home?.({}); + await operations.setOrientation?.({ rotation: 'landscape-left' }); + await operations.tvRemote?.({ button: 'select' }); + + expect(calls).toEqual(['back:default', 'home', 'setOrientation:landscape-left', 'tvRemote']); +}); + +test('binding omits every operation an unavailable fact refused', () => { + const facts = limrunNavigationOperationFacts(iosDevice); + const operations = bindLimrunNavigationOperations({ + device: iosDevice, + signal: new AbortController().signal, + facts, + getInteractor: () => ({}) as unknown as Interactor, + }); + + expect(operations.back).toBeTypeOf('function'); + expect(operations.setOrientation).toBeTypeOf('function'); + expect(operations.home).toBeUndefined(); + expect(operations.tvRemote).toBeUndefined(); +}); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 5f9247ffc7..330ded023a 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -1,9 +1,17 @@ import { + bindProviderBackInteractor, bindProviderFocusInteractor, + bindProviderHomeInteractor, + bindProviderOrientationInteractor, bindProviderScreenshotInteractor, bindProviderSnapshotInteractor, + bindProviderTvRemoteInteractor, bindProviderTypeTextInteractor, + backRuntimeOperationFacts, focusRuntimeOperationFacts, + homeRuntimeOperationFacts, + orientationRuntimeOperationFacts, + tvRemoteRuntimeOperationFacts, typeTextRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; @@ -11,6 +19,21 @@ import type { RuntimeOperationUnavailability } from '@agent-device/contracts/pla import type { DeviceInfo } from '@agent-device/kernel/device'; const available = Object.freeze({ available: true } as const); +const homeUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose home yet.', +} as const); +const tvRemoteUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose tv remote control.', +} as const); +const tvRemoteUnavailableAndroid = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'tv-remote is supported only on Android TV targets.', +} as const); /** * The interactor-backed interaction cells a live Limrun session serves: everything here rides @@ -45,3 +68,72 @@ export function bindLimrunInteractionOperations( ...bindProviderScreenshotInteractor({ device, signal, resolveInteractor }), }); } + +/** + * `back`/`home`/`orientation`/`tvRemote` differ by direct-session platform, unlike focus/type: + * the Android leg rides `session.dependencies.android.createInteractor` (`android.ts`) — the + * SAME factory the local Android family binds, so it carries the identical cell table (parity + * with the local owner, including the `device.target === 'tv'` gate for `tvRemote`). The iOS leg + * (`ios.ts`) implements `back`/`setOrientation` but explicitly refuses `home`/`tvRemote`. + */ +export function limrunNavigationOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + if (liveSessionUnavailable) { + return Object.freeze({ + ...backRuntimeOperationFacts({ back: liveSessionUnavailable }), + ...homeRuntimeOperationFacts({ home: liveSessionUnavailable }), + ...orientationRuntimeOperationFacts({ orientation: liveSessionUnavailable }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: liveSessionUnavailable }), + }); + } + if (device.platform === 'android') { + return Object.freeze({ + ...backRuntimeOperationFacts({ back: available }), + ...homeRuntimeOperationFacts({ home: available }), + ...orientationRuntimeOperationFacts({ orientation: available }), + ...tvRemoteRuntimeOperationFacts({ + tvRemote: device.target === 'tv' ? available : tvRemoteUnavailableAndroid, + }), + }); + } + return Object.freeze({ + ...backRuntimeOperationFacts({ back: available }), + ...homeRuntimeOperationFacts({ home: homeUnavailableIos }), + ...orientationRuntimeOperationFacts({ orientation: available }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: tvRemoteUnavailableIos }), + }); +} + +/** Binds whichever navigation operations {@link limrunNavigationOperationFacts} admitted. */ +export function bindLimrunNavigationOperations( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + facts: Readonly<{ + back: RuntimeOperationUnavailability | { available: true }; + home: RuntimeOperationUnavailability | { available: true }; + setOrientation: RuntimeOperationUnavailability | { available: true }; + tvRemote: RuntimeOperationUnavailability | { available: true }; + }>; + getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; + }>, +) { + const { device, signal, facts } = params; + const resolveInteractor = (runner: RunnerContext) => params.getInteractor(device, runner); + return Object.freeze({ + ...(facts.back.available + ? bindProviderBackInteractor({ device, signal, resolveInteractor }) + : {}), + ...(facts.home.available + ? bindProviderHomeInteractor({ device, signal, resolveInteractor }) + : {}), + ...(facts.setOrientation.available + ? bindProviderOrientationInteractor({ device, signal, resolveInteractor }) + : {}), + ...(facts.tvRemote.available + ? bindProviderTvRemoteInteractor({ device, signal, resolveInteractor }) + : {}), + }); +} diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 1d9d26f017..b97dad7659 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -251,6 +251,25 @@ test('captures through only the active exact WebDriver interactor', async () => reason: 'unsupported-provider-mode', }); expect(binding.operations.readTextAtPoint).toBeUndefined(); + // back/home/orientation ride the same reachable interactor focus/type do. + for (const operation of ['back', 'home', 'setOrientation'] as const) { + expect(binding.facts.operations[operation]).toEqual({ available: true }); + expect(binding.operations[operation]).toBeTypeOf('function'); + } + // tv-remote and every keyboard action always throw unsupported in this interactor regardless + // of reachability: no capability ever declared them. + for (const operation of [ + 'tvRemote', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { + expect(binding.facts.operations[operation]).toMatchObject({ + available: false, + reason: 'unsupported-provider-mode', + }); + expect(binding.operations[operation]).toBeUndefined(); + } await expect( binding.operations.captureSnapshot?.({ options: { interactiveOnly: true } }), ).resolves.toEqual({ backend: 'android', nodes: [] }); @@ -291,6 +310,19 @@ test.each([ expect(facts.operations.focusPoint.available).toBe(state.isSessionActive()); expect(facts.operations.typeText.available).toBe(state.isSessionActive()); expect(facts.operations.readTextAtPoint.available).toBe(false); + // back/home/orientation share focus/type's reachability gate; tv-remote and every keyboard + // action stay unavailable even for an active session with no reachable interactor. + expect(facts.operations.back.available).toBe(state.isSessionActive()); + expect(facts.operations.home.available).toBe(state.isSessionActive()); + expect(facts.operations.setOrientation.available).toBe(state.isSessionActive()); + for (const operation of [ + 'tvRemote', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { + expect(facts.operations[operation].available).toBe(false); + } 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 dfdcf76596..afbda053ff 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -12,6 +12,14 @@ import { typeTextRuntimeOperationFacts, snapshotRuntimeOperationFacts, viewportRuntimeOperationFacts, + bindProviderBackInteractor, + backRuntimeOperationFacts, + bindProviderHomeInteractor, + homeRuntimeOperationFacts, + bindProviderOrientationInteractor, + orientationRuntimeOperationFacts, + tvRemoteRuntimeOperationFacts, + keyboardRuntimeOperationFacts, type AppDeploymentInput, type DeployMaterializedAppInput, type DeviceBinding, @@ -114,6 +122,39 @@ const elementTextUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'WebDriver provider runtimes read element text from the captured tree only.', } as const); +const backUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose back for this device.', +} as const); +const homeUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose home for this device.', +} as const); +const orientationUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose orientation for this device.', +} as const); +/** The WebDriver interactor's own `tvRemote` always throws unsupported (no capability declares + * it), so this cell is unavailable unconditionally rather than gated by interactor reachability. */ +const tvRemoteUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose tv-remote.', +} as const); +/** + * The retired leaf never routed `keyboard` through provider resolution at all — it dispatched + * directly by device platform, bypassing the interactor/provider seam entirely. Restating that as + * a fact means declaring it honestly unavailable here rather than guessing at untested provider + * behavior; see the unit record for the narrowing this states explicitly. + */ +const keyboardUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose keyboard actions.', +} as const); const appStateUnavailable = Object.freeze({ available: false, @@ -211,6 +252,33 @@ export function createWebDriverPlatformRuntimeOwner( }); } +/** The seven interactor-backed operations, each independently gated by its own admitted fact. */ +function webDriverInteractionOperations( + options: WebDriverPlatformRuntimeOptions, + device: DeviceInfo, + signal: AbortSignal, + facts: RuntimeFacts, +): Partial['operations']> { + const resolver = { + device, + signal, + resolveInteractor: (runner: RunnerContext) => options.getInteractor?.(device, runner), + }; + return { + ...(facts.operations.captureSnapshot.available ? bindProviderSnapshotInteractor(resolver) : {}), + ...(facts.operations.captureScreenshot.available + ? bindProviderScreenshotInteractor(resolver) + : {}), + ...(facts.operations.focusPoint.available ? bindProviderFocusInteractor(resolver) : {}), + ...(facts.operations.typeText.available ? bindProviderTypeTextInteractor(resolver) : {}), + ...(facts.operations.back.available ? bindProviderBackInteractor(resolver) : {}), + ...(facts.operations.home.available ? bindProviderHomeInteractor(resolver) : {}), + ...(facts.operations.setOrientation.available + ? bindProviderOrientationInteractor(resolver) + : {}), + }; +} + function bindWebDriverPlatformRuntime( options: WebDriverPlatformRuntimeOptions, device: DeviceInfo, @@ -230,34 +298,7 @@ function bindWebDriverPlatformRuntime( }), facts.operations, ), - ...(facts.operations.captureSnapshot.available - ? bindProviderSnapshotInteractor({ - device, - signal, - resolveInteractor: (runner) => options.getInteractor?.(device, runner), - }) - : {}), - ...(facts.operations.captureScreenshot.available - ? bindProviderScreenshotInteractor({ - device, - signal, - resolveInteractor: (runner) => options.getInteractor?.(device, runner), - }) - : {}), - ...(facts.operations.focusPoint.available - ? bindProviderFocusInteractor({ - device, - signal, - resolveInteractor: (runner) => options.getInteractor?.(device, runner), - }) - : {}), - ...(facts.operations.typeText.available - ? bindProviderTypeTextInteractor({ - device, - signal, - resolveInteractor: (runner) => options.getInteractor?.(device, runner), - }) - : {}), + ...webDriverInteractionOperations(options, device, signal, facts), networkDump: async (input) => { const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); const dump = readRecentNetworkTrafficFromText(recent.text, { @@ -321,6 +362,13 @@ function webDriverFacts( focus: inactiveSession, typeText: inactiveSession, elementText: inactiveSession, + back: inactiveSession, + home: inactiveSession, + orientation: inactiveSession, + tvRemote: inactiveSession, + keyboardStatus: inactiveSession, + keyboardDismiss: inactiveSession, + keyboardEnter: inactiveSession, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: inactiveSession, prepareApplicationOpen: inactiveSession, @@ -345,6 +393,13 @@ function webDriverFacts( focus: focusUnavailable, typeText: typeUnavailable, elementText: elementTextUnavailable, + back: backUnavailable, + home: homeUnavailable, + orientation: orientationUnavailable, + tvRemote: tvRemoteUnavailable, + keyboardStatus: keyboardUnavailable, + keyboardDismiss: keyboardUnavailable, + keyboardEnter: keyboardUnavailable, lifecycle: webDriverLifecycleFacts(device), }); // Both capture cells need the same reachability: an interactor this provider can drive, on a @@ -374,6 +429,19 @@ function webDriverFacts( // reachability and nothing more: this provider drives touch wherever it can drive a capture. ...focusRuntimeOperationFacts({ focus: interactorCell(reachable, focusUnavailable) }), ...typeTextRuntimeOperationFacts({ type: interactorCell(reachable, typeUnavailable) }), + // `back`/`home`/`orientation` ride the same reachable interactor; `tvRemote` always throws + // unsupported in this interactor regardless of reachability (no capability declares it). + ...backRuntimeOperationFacts({ back: interactorCell(reachable, backUnavailable) }), + ...homeRuntimeOperationFacts({ home: interactorCell(reachable, homeUnavailable) }), + ...orientationRuntimeOperationFacts({ + orientation: interactorCell(reachable, orientationUnavailable), + }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: tvRemoteUnavailable }), + ...keyboardRuntimeOperationFacts({ + status: keyboardUnavailable, + dismiss: keyboardUnavailable, + enter: keyboardUnavailable, + }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ensureReady: available, bootTarget: available, diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 843f588e13..b7f41161fd 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -73,7 +73,7 @@ test('daemon modularity baseline records the measured R7 ownership pressure', () Object.values(SESSION_STATE_FIELD_OWNERS).reduce((sum, owners) => sum + owners.length, 0), DAEMON_MODULARITY_BASELINE.sessionState.ownerFileClaims, ); - assert.equal(TYPE_CYCLE_BASELINE, 26); + assert.equal(TYPE_CYCLE_BASELINE, 25); assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 14); assert.equal('daemon' in DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers, false); }); @@ -257,6 +257,6 @@ test('R9 rejects a baseline left above the measured cycle', () => { assert.equal(violations.length, 1); assert.match(violations[0]!.rule, /^R9 /); - assert.match(violations[0]!.message, /dropped to 25 files \(baseline 26\)/); + assert.match(violations[0]!.message, /dropped to 24 files \(baseline 25\)/); assert.match(violations[0]!.message, /Lower LARGEST_TYPE_CYCLE_ZONE_CEILINGS by the same 1/); }); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 75202897eb..b08709782f 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -6,7 +6,10 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { '(root)': 2, core: 8, 'daemon-server': 14, - platforms: 2, + // R42/R43/R45 deleted `vega/plugin.ts`'s `PUBLIC_COMMANDS` import (the retired + // back/home/tv-remote closures were its only consumer), dropping it out of the cycle and + // leaving `apple/plugin.ts` as the platforms zone's sole remaining member. + platforms: 1, }; export const DAEMON_MODULARITY_BASELINE = { diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 3ea4849bd7..9c8a07637b 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -26,7 +26,9 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * `cutoverTableDefects` rejects a duplicate; lifecycle starts at R28 after the accepted * shutdown, install/deploy, and application-lifecycle allocations. Snapshot starts at R32; * diff follows at R33, find at R35, get at R36, is at R37, screenshot at R39, wait at R38, - * focus at R40, and type at R41 — the Wave 4 observation family is complete. + * focus at R40, and type at R41 — the Wave 4 observation family is complete. Wave 5's generic + * leaves follow: back at R42, home at R43, orientation at R44, tv-remote at R45, and the + * action-selected keyboard at R46. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -663,6 +665,118 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ operationOwners: { typeText: ['executeBoundTypeText'] }, }, }, + { + rule: 'R42 back-runtime-cutover', + command: 'back', + subject: 'back navigation', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm; `back` had no dedicated named handler function to retire (its + // legacy body lived inline in the `DISPATCH_HANDLERS` literal). `back` also leaves the + // HarmonyOS overlay that granted it a capability bucket the descriptor never listed. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + runtimeTypeNames: ['BackRuntimeOperations'], + operations: { names: ['back'] }, + singularExecution: { + routes: ['dispatchGenericCommand'], + operations: ['back'], + operationOwners: { back: ['executeBack'] }, + }, + }, + { + rule: 'R43 home-runtime-cutover', + command: 'home', + subject: 'home navigation', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm; `home` had no dedicated named handler function to retire either. + // `home` also leaves the HarmonyOS overlay that granted it a capability bucket the + // descriptor never listed. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + runtimeTypeNames: ['HomeRuntimeOperations'], + operations: { names: ['home'] }, + singularExecution: { + routes: ['dispatchGenericCommand'], + operations: ['home'], + operationOwners: { home: ['executeHome'] }, + }, + }, + { + rule: 'R44 orientation-runtime-cutover', + command: 'orientation', + subject: 'device orientation', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm; `orientation` had no dedicated named handler function (its legacy + // body lived inline in the `DISPATCH_HANDLERS` literal). Its whole remaining legacy + // admission was the Apple family's `supportsOrientation` closure, deleted by name. + routeNames: ['supportsOrientation'], + }, + runtimeTypeNames: ['OrientationRuntimeOperations'], + operations: { names: ['setOrientation'] }, + singularExecution: { + routes: ['dispatchGenericCommand'], + operations: ['setOrientation'], + operationOwners: { setOrientation: ['executeSetOrientation'] }, + }, + }, + { + rule: 'R45 tv-remote-runtime-cutover', + command: 'tv-remote', + subject: 'TV remote control', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its dedicated handler function. + routeNames: ['handleTvRemoteCommand'], + }, + runtimeTypeNames: ['TvRemoteRuntimeOperations'], + operations: { names: ['tvRemote'] }, + singularExecution: { + routes: ['dispatchGenericCommand'], + operations: ['tvRemote'], + operationOwners: { tvRemote: ['executeTvRemote'] }, + }, + }, + { + rule: 'R46 keyboard-runtime-cutover', + command: 'keyboard', + subject: 'software keyboard control', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its three family-branching handler functions in + // `core/dispatch.ts`. `session.ts`'s own `handleKeyboardCommand` keeps its name — what + // changed is what it calls: `resolveBoundKeyboardRuntime` instead of `dispatchCommand`, + // which is why the operation owners below are the ONLY route into the three operations. + // `keyboard` also leaves the HarmonyOS overlay that granted it a capability bucket the + // descriptor never listed. + routeNames: [ + 'handleAndroidKeyboardCommand', + 'handleHarmonyKeyboardCommand', + 'handleIosKeyboardCommand', + ], + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + runtimeTypeNames: ['KeyboardRuntimeOperations'], + operations: { names: ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] }, + singularExecution: { + // `keyboard` is action-selected (R35's lesson): the session route resolves exactly one of + // the three operations per request, binds once, and never all three together. + routes: ['resolveBoundKeyboardRuntime'], + operations: ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'], + operationOwners: { + keyboardStatus: ['executeKeyboardStatus'], + keyboardDismiss: ['executeKeyboardDismiss'], + keyboardEnter: ['executeKeyboardEnter'], + }, + }, + }, { rule: 'R34 viewport-runtime-cutover', command: 'viewport', diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts index 63fe5327a6..0747179ca4 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/platform'; import { - isIosFamily, isMacOs, resolveDeviceAppleOs, DEVICE_TARGETS, @@ -51,11 +50,6 @@ const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => const isIosOs = (device: DeviceInfo): boolean => device.platform === 'apple' && (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); -const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean => - device.platform === 'android' || (isIosFamily(device) && device.target !== 'tv'); -const supportsTvRemote = (device: DeviceInfo): boolean => - (device.platform === 'android' && device.target === 'tv') || - (isIosFamily(device) && device.target === 'tv'); const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.platform !== 'apple' || device.kind !== 'device' || @@ -64,18 +58,17 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef supportsCoreDevicePhysicalOperation(device) ? undefined : 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.'; +// `home`/`keyboard`/`orientation`/`tv-remote` are gone from this table (R42/R43/R44/R45/R46 +// retired their AppleOS-table-reading closures along with their descriptor capability buckets); +// their per-AppleOS admission now lives as owner facts in `packages/platform-apple/src/runtime.ts`. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - home: isNotMacOs, 'app-switcher': isNotMacOs, clipboard: (device) => device.platform === 'android' || device.platform === 'linux' || isMacOs(device) || device.kind === 'simulator', - keyboard: supportsAndroidOrIosNonTv, - orientation: supportsAndroidOrIosNonTv, - 'tv-remote': supportsTvRemote, alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => @@ -88,17 +81,6 @@ const SUPPORTS_REF: Record boolean> = { }; const HINT_REF: Record string | undefined> = { perf: coreDeviceOnlyPhysicalOperationHint, - 'tv-remote': (device) => { - if (device.platform === 'android') { - return device.target === 'tv' - ? undefined - : 'tv-remote is supported only on Android TV targets.'; - } - if (isIosFamily(device)) { - return device.target === 'tv' ? undefined : 'tv-remote is supported only on tvOS devices.'; - } - return isMacOs(device) ? 'tv-remote is supported only on tvOS devices.' : undefined; - }, }; // --------------------------------------------------------------------------- diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index a0eb209a30..e39d098436 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -39,6 +39,13 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre focusPoint: unavailable, typeText: unavailable, ...elementTextRuntimeOperationFacts({ readTextAtPoint: unavailable }), + back: unavailable, + home: unavailable, + setOrientation: unavailable, + tvRemote: unavailable, + keyboardStatus: unavailable, + keyboardDismiss: unavailable, + keyboardEnter: unavailable, }); /** Default facts for tests that are unrelated to application lifecycle commands. */ diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 0b4eb5f5cf..019170ff60 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -46,14 +46,6 @@ const androidEmulator: DeviceInfo = { kind: 'emulator', }; -const androidTvEmulator: DeviceInfo = { - platform: 'android', - id: 'emulator-5556', - name: 'Android TV', - kind: 'emulator', - target: 'tv', -}; - const macOsDevice: DeviceInfo = { platform: 'apple', appleOs: 'macos', @@ -120,24 +112,6 @@ test('device capability matrix stays consistent across shared command groups', ( { device: macOsDevice, expected: true, label: 'on macOS' }, ], }, - { - commands: ['keyboard'], - checks: [ - { device: iosSimulator, expected: true, label: 'on iOS sim' }, - { device: iosDevice, expected: true, label: 'on iOS device' }, - { device: androidDevice, expected: true, label: 'on Android' }, - ], - }, - { - commands: ['tv-remote'], - checks: [ - { device: iosSimulator, expected: false, label: 'on iOS sim' }, - { device: androidDevice, expected: false, label: 'on Android phone' }, - { device: androidTvEmulator, expected: true, label: 'on Android TV' }, - { device: macOsDevice, expected: false, label: 'on macOS' }, - { device: tvOsSimulator, expected: true, label: 'on tvOS simulator' }, - ], - }, { commands: ['gesture', 'swipe'], checks: [ @@ -250,7 +224,7 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only [{ device: macOsDevice, expected: true, label: 'on macOS' }], ); assertCommandSupport( - ['app-switcher', 'home', 'orientation'], + ['app-switcher'], [{ device: macOsDevice, expected: false, label: 'on macOS' }], ); }); @@ -280,16 +254,6 @@ test('tvOS follows iOS capability matrix by device kind', () => { ['settings', 'alert'], [{ device: tvOsSimulator, expected: true, label: 'on tvOS simulator' }], ); - assert.equal( - isCommandSupportedOnDevice('keyboard', tvOsSimulator), - false, - 'keyboard on tvOS simulator', - ); - assert.equal( - isCommandSupportedOnDevice('orientation', tvOsSimulator), - false, - 'orientation on tvOS simulator', - ); }); test('Linux supports desktop interaction commands and blocks mobile/unsupported ones', () => { @@ -317,7 +281,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported [{ device: linuxDevice, expected: true, label: 'on Linux' }], ); assertCommandSupport( - ['alert', 'app-switcher', 'keyboard', 'perf', 'orientation', 'settings', 'trigger-app-event'], + ['alert', 'app-switcher', 'perf', 'settings', 'trigger-app-event'], [{ device: linuxDevice, expected: false, label: 'on Linux' }], ); }); @@ -346,14 +310,10 @@ test('web supports only the initial browser interaction slice', () => { [ 'alert', 'app-switcher', - 'back', 'clipboard', 'gesture', - 'home', - 'keyboard', 'longpress', 'perf', - 'orientation', 'settings', 'swipe', 'trigger-app-event', diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 34a8e574d0..2dd5fe2553 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -106,11 +106,6 @@ const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => const isIosOs = (device: DeviceInfo): boolean => device.platform === 'apple' && (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); -const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean => - device.platform === 'android' || (isIosFamily(device) && device.target !== 'tv'); -const supportsTvRemote = (device: DeviceInfo): boolean => - (device.platform === 'android' && device.target === 'tv') || - (isIosFamily(device) && device.target === 'tv'); const supportsHostAudioProbe = (device: DeviceInfo): boolean => device.platform === 'web' || (process.platform === 'darwin' && @@ -130,16 +125,12 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef // gains/loses a closure (or whose closure body changes) breaks parity. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - home: isNotMacOs, 'app-switcher': isNotMacOs, clipboard: (device) => device.platform === 'android' || device.platform === 'linux' || isMacOs(device) || device.kind === 'simulator', - keyboard: supportsAndroidOrIosNonTv, - orientation: supportsAndroidOrIosNonTv, - 'tv-remote': supportsTvRemote, alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => @@ -148,17 +139,6 @@ const SUPPORTS_REF: Record boolean> = { }; const HINT_REF: Record string | undefined> = { perf: coreDeviceOnlyPhysicalOperationHint, - 'tv-remote': (device) => { - if (device.platform === 'android') { - return device.target === 'tv' - ? undefined - : 'tv-remote is supported only on Android TV targets.'; - } - if (isIosFamily(device)) { - return device.target === 'tv' ? undefined : 'tv-remote is supported only on tvOS devices.'; - } - return isMacOs(device) ? 'tv-remote is supported only on tvOS devices.' : undefined; - }, }; // Independent hardcoded oracle for the platform -> capability-bucket selection @@ -171,18 +151,16 @@ const CAPABILITY_BUCKET_BY_PLATFORM: Record = linux: 'linux', web: 'web', }; -const VEGA_VVD_ONLY_COMMANDS_REF = new Set(['back', 'home', 'tv-remote']); +// R42/R43/R45 deleted the plugin's only `VEGA_VVD_ONLY_COMMANDS` closures (back/home/tv-remote); +// nothing takes their place here since Vega now carries no `supportsByDefault` at all. const HARMONYOS_SUPPORTED_COMMANDS_REF = new Set([ 'perf', - 'back', 'app-switcher', 'click', 'fill', 'find', 'focus', - 'home', 'gesture', - 'keyboard', 'longpress', 'press', 'screenshot', @@ -211,10 +189,7 @@ function isSupportedReference(command: string, device: DeviceInfo): boolean { if (!capability) return true; const byPlatform = capability[CAPABILITY_BUCKET_BY_PLATFORM[device.platform]]; if (!byPlatform) return false; - const supports = - device.platform === 'vega' && VEGA_VVD_ONLY_COMMANDS_REF.has(command) - ? (candidate: DeviceInfo) => candidate.target === 'tv' - : SUPPORTS_REF[command]; + const supports = SUPPORTS_REF[command]; if (supports && !supports(device)) return false; const kind = (device.kind ?? 'unknown') as keyof NonNullable; return byPlatform[kind] === true; @@ -260,14 +235,14 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', () .filter((command) => isCommandSupportedOnDevice(command, HARMONYOS_EMULATOR)) .sort(); + // `back`/`home`/`keyboard` dropped out of the matrix entirely (R42/R43/R46 deleted their + // capability buckets), so they are absent here — not because HarmonyOS admission changed, but + // because there is no bucket left for `isCommandSupportedOnDevice` to consult at all. assert.deepEqual(availableCommands, [ 'app-switcher', - 'back', 'click', 'fill', 'gesture', - 'home', - 'keyboard', 'longpress', 'perf', 'press', @@ -282,15 +257,9 @@ test('(b.2) unsupportedHint closures are verbatim across the full device matrix' for (const command of commands) { const reference = HINT_REF[command]; for (const device of SAMPLE_DEVICES) { - const expected = - device.platform === 'vega' && VEGA_VVD_ONLY_COMMANDS_REF.has(command) - ? device.kind === 'emulator' && device.target === 'tv' - ? undefined - : `${command} currently supports only Vega Virtual Devices.` - : reference?.(device); assert.equal( unsupportedHintForDevice(command, device), - expected, + reference?.(device), `${command} hint on ${device.id}`, ); } @@ -351,27 +320,13 @@ test('(b.2) the relocated Apple closures match the independent command contracts }); test('(b.2) non-Apple families only carry their own non-portable support gates', () => { - // Most relocated closures are Apple-only. Audio is the one host-dependent command - // that also gates Android emulator support on macOS hosts, so Android carries only - // that command-specific predicate. - assert.deepEqual(Object.keys(getPlugin('android').capability.supportsByDefault ?? {}), [ - 'audio', - 'tv-remote', - ]); - assert.deepEqual(Object.keys(getPlugin('android').capability.unsupportedHintByDefault ?? {}), [ - 'tv-remote', - ]); - assert.deepEqual(Object.keys(getPlugin('vega').capability.supportsByDefault ?? {}), [ - 'back', - 'home', - 'tv-remote', - ]); - assert.deepEqual(Object.keys(getPlugin('vega').capability.unsupportedHintByDefault ?? {}), [ - 'back', - 'home', - 'tv-remote', - ]); - for (const platform of ['linux', 'web'] as const) { + // Most relocated closures are Apple-only. Audio is the one host-dependent command that also + // gates Android emulator support on macOS hosts, so Android carries only that predicate — + // R45 deleted its `tv-remote` closure along with the descriptor's capability bucket. + assert.deepEqual(Object.keys(getPlugin('android').capability.supportsByDefault ?? {}), ['audio']); + assert.equal(getPlugin('android').capability.unsupportedHintByDefault, undefined); + // R42/R43/R45 deleted Vega's only closures (back/home/tv-remote); nothing replaces them. + for (const platform of ['vega', 'linux', 'web'] as const) { const capability = getPlugin(platform).capability; assert.equal(capability.supportsByDefault, undefined, `${platform} has no supportsByDefault`); assert.equal( diff --git a/src/core/__tests__/dispatch-back.test.ts b/src/core/__tests__/dispatch-back.test.ts index 3cc10e7df5..f7eca6bfb9 100644 --- a/src/core/__tests__/dispatch-back.test.ts +++ b/src/core/__tests__/dispatch-back.test.ts @@ -1,41 +1,24 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; import { dispatchCommand } from '../dispatch.ts'; import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; -test('dispatch back defaults to in-app mode and keeps Android back on keyevent 4', async () => { - await withMockedAdb('agent-device-dispatch-back-modes-', async (argsLogPath) => { - for (const backMode of [undefined, 'in-app', 'system'] as const) { - const result = await dispatchCommand(ANDROID_EMULATOR, 'back', [], undefined, { - backMode, - }); +// R42 retired `back` from `DISPATCH_HANDLERS`; its ADB-command-level parity pin now lives on +// `backAndroid` directly (`src/platforms/android/__tests__/input-actions.test.ts`), and its +// admitted-runtime behavior in `src/daemon/__tests__/back-runtime.test.ts`. +test('legacy dispatch no longer reaches the back leaf', async () => { + await withMockedAdb('agent-device-dispatch-back-retired-', async (argsLogPath) => { + await assert.rejects( + dispatchCommand(ANDROID_EMULATOR, 'back', [], undefined, { backMode: 'in-app' }), + { code: 'INVALID_ARGS', message: 'Unknown command: back' }, + ); - assert.equal(result?.action, 'back'); - assert.equal(result?.mode, backMode ?? 'in-app'); - } - - const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); - assert.deepEqual(args, [ - '-s', - 'emulator-5554', - 'shell', - 'input', - 'keyevent', - '4', - '-s', - 'emulator-5554', - 'shell', - 'input', - 'keyevent', - '4', - '-s', - 'emulator-5554', - 'shell', - 'input', - 'keyevent', - '4', - ]); + const { promises: fs } = await import('node:fs'); + await assert.rejects( + fs.readFile(argsLogPath, 'utf8'), + { code: 'ENOENT' }, + 'no adb command was ever issued, so the args log was never created', + ); }); }); diff --git a/src/core/__tests__/dispatch-keyboard.test.ts b/src/core/__tests__/dispatch-keyboard.test.ts index 02afe230f0..deffa930cd 100644 --- a/src/core/__tests__/dispatch-keyboard.test.ts +++ b/src/core/__tests__/dispatch-keyboard.test.ts @@ -1,6 +1,5 @@ -import { beforeEach, test, vi } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -15,109 +14,39 @@ import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); -beforeEach(() => { - vi.resetAllMocks(); - mockRunAppleRunnerCommand.mockResolvedValue({ - message: 'keyboardReturn', - wasVisible: true, - visible: false, - }); -}); - -test('dispatch keyboard enter sends Android ENTER keyevent', async () => { - await withMockedAdb('agent-device-dispatch-keyboard-enter-', async (argsLogPath) => { - const result = await dispatchCommand(ANDROID_EMULATOR, 'keyboard', ['enter']); - - assert.equal(result?.action, 'enter'); - const logged = await fs.readFile(argsLogPath, 'utf8'); - assert.match(logged, /shell\ninput\nkeyevent\nENTER/); - }); -}); - -test('dispatch keyboard enter sends native iOS keyboard return command', async () => { - const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['return'], undefined, { - appBundleId: 'com.example.app', - }); - - assert.equal(result?.action, 'enter'); - assert.equal(result?.wasVisible, true); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 1); - assert.deepEqual(mockRunAppleRunnerCommand.mock.calls[0]?.[1], { - command: 'keyboardReturn', - appBundleId: 'com.example.app', - }); -}); - -// #1598: the response must disclose which mechanism the runner used to -// resign the keyboard — only the keyboard's own dismiss key is a mechanism the runner vouches for; an unrecognized wire value must degrade to the bare message rather than a false claim. The safe-area tap was removed (#1606 review): -// different reliability guarantees, and the CLI/SDK message must say which -// one actually fired rather than a bare "dismissed". -test('dispatch keyboard dismiss surfaces the dismissKey mechanism and message', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - message: 'keyboardDismiss', - wasVisible: true, - visible: false, - dismissed: true, - keyboardDismissMechanism: 'dismissKey', - }); - - const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, { - appBundleId: 'com.example.app', - }); - - assert.equal(result?.action, 'dismiss'); - assert.equal(result?.dismissed, true); - assert.equal(result?.mechanism, 'dismissKey'); - assert.equal(result?.message, 'Keyboard dismissed via its dismiss key'); -}); - -test('dispatch keyboard dismiss degrades an unrecognized mechanism to the bare message', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - message: 'keyboardDismiss', - wasVisible: true, - visible: false, - dismissed: true, - keyboardDismissMechanism: 'legacySafeAreaTap', - }); - - const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, { - appBundleId: 'com.example.app', - }); - - assert.equal(result?.mechanism, 'legacySafeAreaTap'); - assert.equal(String(result?.message), 'Keyboard dismissed'); -}); - -test('dispatch keyboard dismiss omits mechanism when every mechanism failed', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - message: 'keyboardDismiss', - wasVisible: true, - visible: true, - dismissed: false, - }); - - const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, { - appBundleId: 'com.example.app', - }); - - assert.equal(result?.dismissed, false); - assert.equal(result?.mechanism, undefined); - assert.equal(result?.message, 'Keyboard already hidden'); -}); - -test('dispatch keyboard dismiss omits mechanism when the keyboard was never visible', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - message: 'keyboardDismiss', - wasVisible: false, - visible: false, - dismissed: false, - }); - - const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, { - appBundleId: 'com.example.app', - }); - - assert.equal(result?.wasVisible, false); - assert.equal(result?.mechanism, undefined); - assert.equal(result?.message, 'Keyboard already hidden'); +// R46 retired `keyboard` from `DISPATCH_HANDLERS` and its dedicated +// `handleAndroidKeyboardCommand`/`handleHarmonyKeyboardCommand`/`handleIosKeyboardCommand` +// helpers. Its ADB ENTER-keyevent parity pin now lives on `pressAndroidEnter` directly +// (`src/platforms/android/__tests__/input-actions.test.ts`); its iOS runner routing (including +// the dismiss-mechanism disclosure and degradation cases) is covered by the Apple interactor's +// own runner-provider suite (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`) +// and by `src/daemon/__tests__/keyboard-runtime.test.ts`, which also covers the admitted-runtime +// action-selection and per-platform response shapes. +test('legacy dispatch no longer reaches the keyboard leaf on Android', async () => { + await withMockedAdb('agent-device-dispatch-keyboard-retired-android-', async (argsLogPath) => { + await assert.rejects(dispatchCommand(ANDROID_EMULATOR, 'keyboard', ['enter']), { + code: 'INVALID_ARGS', + message: 'Unknown command: keyboard', + }); + + const { promises: fs } = await import('node:fs'); + await assert.rejects( + fs.readFile(argsLogPath, 'utf8'), + { code: 'ENOENT' }, + 'no adb command was ever issued, so the args log was never created', + ); + }); +}); + +test('legacy dispatch no longer reaches the keyboard leaf on iOS', async () => { + await assert.rejects( + dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, { + appBundleId: 'com.example.app', + }), + { + code: 'INVALID_ARGS', + message: 'Unknown command: keyboard', + }, + ); + assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); }); diff --git a/src/core/__tests__/dispatch-orientation.test.ts b/src/core/__tests__/dispatch-orientation.test.ts index 6dd6ba2c02..c1825fe1b8 100644 --- a/src/core/__tests__/dispatch-orientation.test.ts +++ b/src/core/__tests__/dispatch-orientation.test.ts @@ -1,6 +1,5 @@ -import { beforeEach, test, vi } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -15,51 +14,25 @@ import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); -beforeEach(() => { - vi.resetAllMocks(); -}); - -test('dispatch orientation normalizes value aliases before Android execution', async () => { - await withMockedAdb('agent-device-dispatch-orientation-android-', async (argsLogPath) => { - const result = await dispatchCommand(ANDROID_EMULATOR, 'orientation', ['left']); - - assert.equal(result?.action, 'orientation'); - assert.equal(result?.orientation, 'landscape-left'); - - const logged = await fs.readFile(argsLogPath, 'utf8'); - assert.match(logged, /shell\nsettings\nput\nsystem\naccelerometer_rotation\n0/); - assert.match(logged, /shell\nsettings\nput\nsystem\nuser_rotation\n1/); +// R44 retired `orientation` from `DISPATCH_HANDLERS`. Its ADB-command-level parity pin now lives +// on `setAndroidOrientation` directly (`src/platforms/android/__tests__/input-actions.test.ts`); +// its iOS runner routing (including the mismatched-readback rejection) is covered by the Apple +// interactor's own runner-provider suite +// (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`); its admitted-runtime +// behavior is covered in `src/daemon/__tests__/orientation-runtime.test.ts`. +test('legacy dispatch no longer reaches the orientation leaf on Android', async () => { + await withMockedAdb('agent-device-dispatch-orientation-retired-android-', async () => { + await assert.rejects(dispatchCommand(ANDROID_EMULATOR, 'orientation', ['left']), { + code: 'INVALID_ARGS', + message: 'Unknown command: orientation', + }); }); }); -test('dispatch orientation sends normalized orientation to the iOS runner', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - message: 'rotate', - orientation: 'landscape-right', - }); - const result = await dispatchCommand(IOS_DEVICE, 'orientation', ['right'], undefined, { - appBundleId: 'com.example.app', +test('legacy dispatch no longer reaches the orientation leaf on iOS', async () => { + await assert.rejects(dispatchCommand(IOS_DEVICE, 'orientation', ['left']), { + code: 'INVALID_ARGS', + message: 'Unknown command: orientation', }); - - assert.equal(result?.action, 'orientation'); - assert.equal(result?.orientation, 'landscape-right'); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 1); - assert.deepEqual(mockRunAppleRunnerCommand.mock.calls[0]?.[1], { - // `rotate` is the runner-protocol command name; the CLI command is `orientation`. - command: 'rotate', - orientation: 'landscape-right', - appBundleId: 'com.example.app', - }); -}); - -test('dispatch orientation rejects a mismatched iOS runner readback', async () => { - mockRunAppleRunnerCommand.mockResolvedValue({ - message: 'rotate', - orientation: 'portrait', - }); - - await assert.rejects( - dispatchCommand(IOS_DEVICE, 'orientation', ['left']), - /observed portrait after requesting landscape-left/, - ); + assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); }); diff --git a/src/core/__tests__/dispatch-tv-remote.test.ts b/src/core/__tests__/dispatch-tv-remote.test.ts index 6348e3123e..bb09d9fd19 100644 --- a/src/core/__tests__/dispatch-tv-remote.test.ts +++ b/src/core/__tests__/dispatch-tv-remote.test.ts @@ -1,6 +1,5 @@ -import { beforeEach, test, vi } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -9,67 +8,32 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi }); import { dispatchCommand } from '../dispatch.ts'; -import { AppError } from '@agent-device/kernel/errors'; import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { - ANDROID_EMULATOR, - ANDROID_TV_DEVICE, - TVOS_SIMULATOR, -} from '../../__tests__/test-utils/device-fixtures.ts'; +import { ANDROID_TV_DEVICE, TVOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); -beforeEach(() => { - vi.resetAllMocks(); - mockRunAppleRunnerCommand.mockResolvedValue({ message: 'remotePress' }); -}); - -test('dispatch tv-remote sends Android TV D-pad keyevents', async () => { - await withMockedAdb('agent-device-dispatch-tv-remote-', async (argsLogPath) => { - const result = await dispatchCommand(ANDROID_TV_DEVICE, 'tv-remote', ['right']); - - assert.equal(result?.action, 'tv-remote'); - assert.equal(result?.button, 'right'); - const logged = await fs.readFile(argsLogPath, 'utf8'); - assert.match(logged, /shell\ninput\nkeyevent\nKEYCODE_DPAD_RIGHT/); - }); -}); - -test('dispatch tv-remote maps Android duration to longpress keyevent', async () => { - await withMockedAdb('agent-device-dispatch-tv-remote-longpress-', async (argsLogPath) => { - const result = await dispatchCommand(ANDROID_TV_DEVICE, 'tv-remote', ['select'], undefined, { - durationMs: 500, +// R45 retired `tv-remote` from `DISPATCH_HANDLERS` and its dedicated handler function. Its +// ADB D-pad-keyevent parity pin now lives on `pressAndroidTvRemote` directly +// (`src/platforms/android/__tests__/input-actions.test.ts`); its tvOS runner routing is covered +// by the Apple interactor's own runner-provider suite +// (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`); its TV-target admission +// (formerly an in-handler check, now an owner fact) and admitted-runtime behavior are covered in +// `src/daemon/__tests__/tv-remote-runtime.test.ts`. +test('legacy dispatch no longer reaches the tv-remote leaf on Android TV', async () => { + await withMockedAdb('agent-device-dispatch-tv-remote-retired-android-', async () => { + await assert.rejects(dispatchCommand(ANDROID_TV_DEVICE, 'tv-remote', ['right']), { + code: 'INVALID_ARGS', + message: 'Unknown command: tv-remote', }); - - assert.equal(result?.durationMs, 500); - const logged = await fs.readFile(argsLogPath, 'utf8'); - assert.match(logged, /shell\ninput\nkeyevent\n--longpress\nKEYCODE_DPAD_CENTER/); }); }); -test('dispatch tv-remote sends native tvOS remotePress command', async () => { - const result = await dispatchCommand(TVOS_SIMULATOR, 'tv-remote', ['back'], undefined, { - appBundleId: 'com.example.tv', - durationMs: 250, - }); - - assert.equal(result?.button, 'back'); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 1); - assert.deepEqual(mockRunAppleRunnerCommand.mock.calls[0]?.[1], { - command: 'remotePress', - remoteButton: 'menu', - appBundleId: 'com.example.tv', - durationMs: 250, +test('legacy dispatch no longer reaches the tv-remote leaf on tvOS', async () => { + await assert.rejects(dispatchCommand(TVOS_SIMULATOR, 'tv-remote', ['back']), { + code: 'INVALID_ARGS', + message: 'Unknown command: tv-remote', }); -}); - -test('dispatch tv-remote rejects non-TV targets before platform input', async () => { - await assert.rejects( - () => dispatchCommand(ANDROID_EMULATOR, 'tv-remote', ['down']), - (error: unknown) => - error instanceof AppError && - error.code === 'UNSUPPORTED_OPERATION' && - /TV targets/.test(error.message), - ); + assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); }); diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 9b801bd0c1..ad4e572ebc 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -38,13 +38,10 @@ const WEB_DEVICE: KindMatrix = { device: true }; const HARMONYOS_ALL: KindMatrix = { emulator: true, device: true }; const HARMONYOS_SUPPORTED_COMMANDS = new Set([ 'perf', - 'back', 'app-switcher', 'click', 'fill', - 'home', 'gesture', - 'keyboard', 'longpress', 'press', 'scroll', diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 2245f9a1a7..b9b31ed807 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -49,6 +49,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.apps, PUBLIC_COMMANDS.artifacts, + PUBLIC_COMMANDS.back, PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.boot, PUBLIC_COMMANDS.capabilities, @@ -60,12 +61,15 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.find, PUBLIC_COMMANDS.focus, PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.home, PUBLIC_COMMANDS.install, PUBLIC_COMMANDS.installFromSource, PUBLIC_COMMANDS.is, + PUBLIC_COMMANDS.keyboard, PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.network, PUBLIC_COMMANDS.open, + PUBLIC_COMMANDS.orientation, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.push, PUBLIC_COMMANDS.record, @@ -76,6 +80,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.snapshot, PUBLIC_COMMANDS.test, PUBLIC_COMMANDS.trace, + PUBLIC_COMMANDS.tvRemote, PUBLIC_COMMANDS.type, PUBLIC_COMMANDS.viewport, PUBLIC_COMMANDS.wait, @@ -216,6 +221,10 @@ test('generic route commands that reach platform dispatch declare the dispatch f PUBLIC_COMMANDS.focus, PUBLIC_COMMANDS.screenshot, PUBLIC_COMMANDS.viewport, + PUBLIC_COMMANDS.back, + PUBLIC_COMMANDS.home, + PUBLIC_COMMANDS.orientation, + PUBLIC_COMMANDS.tvRemote, ]); for (const descriptor of commandDescriptors) { diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 091a4183ec..3ac63bf00f 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -39,6 +39,11 @@ import { focusRuntimeUse, typeTextRuntimeUse, viewportRuntimeUse, + backRuntimeUse, + homeRuntimeUse, + orientationRuntimeUse, + tvRemoteRuntimeUse, + keyboardRuntimePlanUses, } from '@agent-device/contracts/platform'; import { readDeclaredPlatformExecution } from './platform-execution-entry.ts'; import type { @@ -224,7 +229,6 @@ function readOnlySubactionRecordingEffect( const APPLE_SIM_AND_DEVICE = { simulator: true, device: true }; const ANDROID_ALL = { emulator: true, device: true, unknown: true }; -const VEGA_VVD = { emulator: true }; const LINUX_DEVICE = { device: true }; const LINUX_NONE = {}; @@ -759,6 +763,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', + // R46 retires this command's capability bucket and its `dispatch` leaf together: admission is + // whichever action-selected fact (`keyboardStatus`/`keyboardDismiss`/`keyboardEnter`) the + // parsed action names, and the only execution is that one bound operation (ADR 0019 §9). recordsSessionAction: true, recordingEffect: keyboardRecordingEffect, daemon: { @@ -766,15 +773,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: keyboardRefFrameEffect, androidBlockingDialogGuard: true, }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: keyboardRuntimePlanUses }, }, { name: 'install', @@ -1228,14 +1229,23 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', - ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, - capability: { - ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS.capability, - vega: VEGA_VVD, + // R42 retires this command's capability bucket and its `dispatch` leaf together: admission is + // the owner's `back` fact, and the only execution is the bound operation. The remaining + // traits are `GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS` minus those two, plus the + // postActionObservation timeout trait it kept — admission-independent, like `type` kept its + // dialog guard. + recordsSessionAction: true, + recordingEffect: 'mutates-app', + deviceClaimPolicy: 'require-owner', + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + androidBlockingDialogGuard: true, }, timeoutPolicy: postActionObservationTimeoutPolicy('back', DEFAULT_TIMEOUT_POLICY), postActionObservation: postActionObservation('back'), - platformExecution: LEGACY_PLATFORM_EXECUTION, + batchable: true, + platformExecution: { kind: 'device-runtime', uses: [backRuntimeUse] }, }, { name: 'gesture', @@ -1261,12 +1271,20 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', - ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, - capability: { - ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS.capability, - vega: VEGA_VVD, + // R43 retires this command's capability bucket and its `dispatch` leaf together: admission is + // the owner's `home` fact, and the only execution is the bound operation. The remaining + // traits are `GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS` minus those two. + recordsSessionAction: true, + recordingEffect: 'mutates-app', + deviceClaimPolicy: 'require-owner', + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + androidBlockingDialogGuard: true, }, - platformExecution: LEGACY_PLATFORM_EXECUTION, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: true, + platformExecution: { kind: 'device-runtime', uses: [homeRuntimeUse] }, }, { name: 'tv-remote', @@ -1274,6 +1292,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'tvRemote' }, frameworkTier: 'extended', + // R45 retires this command's capability bucket and its `dispatch` leaf together: admission is + // the owner's `tvRemote` fact, and the only execution is the bound operation. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { @@ -1281,16 +1301,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - vega: VEGA_VVD, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [tvRemoteRuntimeUse] }, }, { name: 'orientation', @@ -1298,6 +1311,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', + // R44 retires this command's capability bucket and its `dispatch` leaf together: admission is + // the owner's `setOrientation` fact, and the only execution is the bound operation. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { @@ -1305,15 +1320,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [orientationRuntimeUse] }, }, { name: 'scroll', diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index b71fbb31a4..b705b65fc4 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -1,14 +1,10 @@ -import { parseDeviceRotation } from '@agent-device/contracts/device'; import type { GesturePlan, Interactor, RunnerContext } from '@agent-device/contracts/interaction'; -import { parseTvRemoteButton } from '@agent-device/contracts/interaction'; -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { emitDiagnostic, withDiagnosticTimer } from '../utils/diagnostics.ts'; -import { isKeyboardAction, type KeyboardAction } from '../utils/keyboard-actions.ts'; import { readLocationCoordinate } from '../utils/location-coordinates.ts'; import { successText, withSuccessText } from '../utils/success-text.ts'; -import { requireIntInRange } from '../utils/validation.ts'; import { parseTriggerAppEventArgs, resolveAppEventUrl } from './app-events.ts'; import type { DescriptorDispatchCommandName } from './command-descriptor/registry.ts'; import type { DispatchContext } from './dispatch-context.ts'; @@ -149,29 +145,11 @@ const DISPATCH_HANDLERS: Record = { handleScrollCommand(interactor, positionals, context), 'trigger-app-event': ({ device, interactor, positionals, context }) => handleTriggerAppEventCommand(device, interactor, positionals, context), - back: async ({ interactor, context }) => { - await interactor.back(context?.backMode); - return { action: 'back', mode: context?.backMode ?? 'in-app', ...successText('Back') }; - }, - home: async ({ interactor }) => { - await interactor.home(); - return { action: 'home', ...successText('Home') }; - }, - orientation: async ({ interactor, positionals }) => { - const requestedOrientation = parseDeviceRotation(positionals[0]); - const result = await interactor.setOrientation(requestedOrientation); - const orientation = result?.orientation ?? requestedOrientation; - return { action: 'orientation', orientation, ...successText(`Rotated to ${orientation}`) }; - }, 'app-switcher': async ({ interactor }) => { await interactor.appSwitcher(); return { action: 'app-switcher', ...successText('Opened app switcher') }; }, clipboard: ({ interactor, positionals }) => handleClipboardCommand(interactor, positionals), - keyboard: ({ device, positionals, context, runnerCtx }) => - handleKeyboardCommand(device, positionals, context, runnerCtx), - 'tv-remote': ({ device, interactor, positionals, context }) => - handleTvRemoteCommand(device, interactor, positionals, context), settings: ({ device, interactor, positionals, context }) => handleSettingsCommand(device, interactor, positionals, context), }; @@ -252,192 +230,6 @@ async function handleClipboardCommand( }; } -async function handleTvRemoteCommand( - device: DeviceInfo, - interactor: Interactor, - positionals: string[], - context: DispatchContext | undefined, -): Promise> { - if (device.target !== 'tv') { - throw new AppError('UNSUPPORTED_OPERATION', 'tv-remote is supported only on TV targets', { - hint: 'Select an Android TV, tvOS, or Vega OS target with --target tv.', - }); - } - if (positionals.length !== 1) { - throw new AppError('INVALID_ARGS', 'tv-remote requires exactly one button'); - } - const button = parseTvRemoteButton(positionals[0]); - const durationMs = - context?.durationMs === undefined - ? undefined - : requireIntInRange(context.durationMs, 'durationMs', 0, 10_000); - await interactor.tvRemote(button, durationMs); - return { - action: 'tv-remote', - button, - ...(durationMs !== undefined ? { durationMs } : {}), - ...successText(`Pressed TV remote ${button}`), - }; -} - -async function handleKeyboardCommand( - device: DeviceInfo, - positionals: string[], - context: DispatchContext | undefined, - runnerCtx: RunnerContext, -): Promise> { - const action = (positionals[0] ?? 'status').toLowerCase(); - if (!isKeyboardAction(action)) { - throw new AppError( - 'INVALID_ARGS', - 'keyboard requires a subcommand: status, get, dismiss, enter, or return', - ); - } - if (positionals.length > 1) { - throw new AppError('INVALID_ARGS', 'keyboard accepts at most one subcommand argument'); - } - if (device.platform === 'android') { - return await handleAndroidKeyboardCommand(device, action); - } - if (device.platform === 'harmonyos') { - return await handleHarmonyKeyboardCommand(device, action); - } - if (isIosFamily(device)) { - return await handleIosKeyboardCommand(device, action, context, runnerCtx); - } - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'keyboard is supported only on Android, HarmonyOS, and iOS', - ); -} - -async function handleHarmonyKeyboardCommand( - device: DeviceInfo, - action: KeyboardAction, -): Promise> { - if (action !== 'dismiss' && action !== 'enter' && action !== 'return') { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'keyboard status/get is not available through the public HarmonyOS HDC API; use keyboard dismiss or enter', - ); - } - const { pressHarmonyKeyboardKey } = await import('../platforms/harmonyos/input-actions.ts'); - const key = action === 'dismiss' ? 'Back' : 'Enter'; - await pressHarmonyKeyboardKey(device, key); - return { - platform: 'harmonyos', - action: action === 'dismiss' ? 'dismiss' : 'enter', - ...successText(action === 'dismiss' ? 'Keyboard dismissed' : 'Keyboard enter pressed'), - }; -} - -async function handleAndroidKeyboardCommand( - device: DeviceInfo, - action: KeyboardAction, -): Promise> { - if (action === 'enter' || action === 'return') { - const { pressAndroidEnter } = await import('../platforms/android/input-actions.ts'); - await pressAndroidEnter(device); - return { - platform: 'android', - action: 'enter', - ...successText('Keyboard enter pressed'), - }; - } - if (action === 'dismiss') { - const { dismissAndroidKeyboard } = await import('../platforms/android/device-input-state.ts'); - const result = await dismissAndroidKeyboard(device); - return { - platform: 'android', - action: 'dismiss', - attempts: result.attempts, - wasVisible: result.wasVisible, - dismissed: result.dismissed, - visible: result.visible, - inputType: result.inputType, - type: result.type, - inputMethodPackage: result.inputMethodPackage, - focusedPackage: result.focusedPackage, - focusedResourceId: result.focusedResourceId, - inputOwner: result.inputOwner, - }; - } - const { getAndroidKeyboardState } = await import('../platforms/android/device-input-state.ts'); - const state = await getAndroidKeyboardState(device); - return { - platform: 'android', - action: 'status', - visible: state.visible, - inputType: state.inputType, - type: state.type, - inputMethodPackage: state.inputMethodPackage, - focusedPackage: state.focusedPackage, - focusedResourceId: state.focusedResourceId, - inputOwner: state.inputOwner, - }; -} - -async function handleIosKeyboardCommand( - device: DeviceInfo, - action: KeyboardAction, - context: DispatchContext | undefined, - runnerCtx: RunnerContext, -): Promise> { - if (action !== 'dismiss' && action !== 'enter' && action !== 'return') { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'keyboard status/get is currently supported only on Android; use keyboard dismiss or enter on iOS', - ); - } - if (action === 'enter' || action === 'return') { - const { runAppleRunnerCommand } = - await import('../platforms/apple/core/runner/runner-client.ts'); - const result = await runAppleRunnerCommand( - device, - { command: 'keyboardReturn', appBundleId: context?.appBundleId }, - runnerCtx, - ); - return { - platform: 'ios', - action: 'enter', - visible: result.visible, - wasVisible: result.wasVisible, - ...successText('Keyboard enter pressed'), - }; - } - const { runAppleRunnerCommand } = await import('../platforms/apple/core/runner/runner-client.ts'); - const result = await runAppleRunnerCommand( - device, - { command: 'keyboardDismiss', appBundleId: context?.appBundleId }, - runnerCtx, - ); - const mechanism = - typeof result.keyboardDismissMechanism === 'string' - ? result.keyboardDismissMechanism - : undefined; - return { - platform: 'ios', - action: 'dismiss', - wasVisible: result.wasVisible, - dismissed: result.dismissed, - visible: result.visible, - mechanism, - ...successText(iosKeyboardDismissMessage(result.dismissed === true, mechanism)), - }; -} - -// Discloses which mechanism actually resigned the keyboard (#1598): a -// Discloses that the keyboard's own dismiss key did the work (#1598); a bare -// "dismissed" would leave the caller unable to tell a vouched-for control tap -// from app-side coincidence. -function iosKeyboardDismissMessage(dismissed: boolean, mechanism: string | undefined): string { - if (!dismissed) return 'Keyboard already hidden'; - if (mechanism === 'dismissKey') { - return 'Keyboard dismissed via its dismiss key'; - } - return 'Keyboard dismissed'; -} - async function handleSettingsCommand( device: DeviceInfo, interactor: Interactor, diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 391e17e003..00a6f66c60 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -11,6 +11,7 @@ import { homeAndroid, longPressAndroid, pressAndroid, + pressAndroidEnter, pressAndroidTvRemote, scrollAndroid, setAndroidOrientation, @@ -25,6 +26,8 @@ import { type AndroidAdbProvider, } from '../../platforms/android/adb-executor.ts'; import { + dismissAndroidKeyboard, + getAndroidKeyboardState, readAndroidClipboardText, writeAndroidClipboardText, } from '../../platforms/android/device-input-state.ts'; @@ -104,6 +107,12 @@ export function createAndroidInteractor( setOrientation: (orientation) => setAndroidOrientation(device, orientation), appSwitcher: () => appSwitcherAndroid(device), tvRemote: (button, durationMs) => pressAndroidTvRemote(device, button, durationMs), + keyboardStatus: () => getAndroidKeyboardState(device), + keyboardDismiss: () => dismissAndroidKeyboard(device), + keyboardEnter: async () => { + await pressAndroidEnter(device); + return {}; + }, readClipboard: () => readAndroidClipboardText(device), writeClipboard: (text) => writeAndroidClipboardText(device, text), setSetting: (setting, state, appId, options) => diff --git a/src/core/interactors/harmonyos.ts b/src/core/interactors/harmonyos.ts index 8f8b07c79b..9e25d8b318 100644 --- a/src/core/interactors/harmonyos.ts +++ b/src/core/interactors/harmonyos.ts @@ -12,6 +12,7 @@ import { longPressHarmony, performHarmonyGesture, pressHarmony, + pressHarmonyKeyboardKey, scrollHarmony, setHarmonyOrientation, typeHarmony, @@ -51,6 +52,14 @@ export function createHarmonyInteractor(device: DeviceInfo, _runner?: RunnerCont setOrientation: (orientation) => setHarmonyOrientation(device, orientation), appSwitcher: () => appSwitcherHarmony(device), tvRemote: unsupported('tv-remote'), + keyboardDismiss: async () => { + await pressHarmonyKeyboardKey(device, 'Back'); + return {}; + }, + keyboardEnter: async () => { + await pressHarmonyKeyboardKey(device, 'Enter'); + return {}; + }, readClipboard: unsupported('clipboard'), writeClipboard: unsupported('clipboard'), setSetting: (setting, state, appId) => setHarmonySetting(device, setting, state, appId), diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index ba5fcfb65b..6fdd6eae69 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -25,11 +25,6 @@ const androidPlugin = { bucket: 'android', supportsByDefault: { [PUBLIC_COMMANDS.audio]: isAudioProbeSupportedDevice, - [PUBLIC_COMMANDS.tvRemote]: (device) => device.target === 'tv', - }, - unsupportedHintByDefault: { - [PUBLIC_COMMANDS.tvRemote]: (device) => - device.target === 'tv' ? undefined : 'tv-remote is supported only on Android TV targets.', }, }, // Android exposes explicit frame-health and memory observations. diff --git a/src/daemon/__tests__/back-runtime.test.ts b/src/daemon/__tests__/back-runtime.test.ts new file mode 100644 index 0000000000..0435562139 --- /dev/null +++ b/src/daemon/__tests__/back-runtime.test.ts @@ -0,0 +1,179 @@ +import { expect, test, vi } from 'vitest'; +import { + backRuntimeOperationFacts, + backRuntimeUse, + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type DeviceRuntimeGateway, + type PlatformRuntimeOperations, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform'; +import { deviceShape } from '@agent-device/kernel/device'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { activateCompleteRefFrame } from '../ref-frame.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; +import { resolveBoundBackRuntime } from '../back-runtime.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +const appleDevice = { + id: 'ios-simulator', + name: 'iPhone', + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + target: 'mobile', + booted: true, +} as const; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind' as const, + hint: 'back is supported on Apple simulators and physical devices.', +}); + +function backExecutionParams( + dispatchContext: GenericPlatformExecutionParams['dispatchContext'] = {}, +): GenericPlatformExecutionParams { + const session = makeSession('back-runtime', { device: appleDevice }); + return { + session, + sessionName: session.name, + logPath: '/tmp/daemon.log', + command: 'back', + request: { command: 'back', positionals: [], token: 't', session: session.name }, + positionals: [], + out: undefined, + dispatchContext, + }; +} + +function runtimeHarness(fact: RuntimeOperationFact = available) { + const back = vi.fn(async () => undefined); + const facts: RuntimeFacts = { + device: { ...deviceShape(appleDevice), providerMode: 'local' }, + operations: { back: fact } as RuntimeFacts['operations'], + }; + const binding = { + device: appleDevice, + owner: localRuntimeOwner('apple'), + facts, + operations: { back }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + const bind = vi.fn(async () => binding); + const gateway: DeviceRuntimeGateway = { + inspectFacts, + bind, + shutdown: async () => {}, + }; + return { back, inspectFacts, bindDevice, bind, gateway }; +} + +test('resolves one admitted binding and drives one back navigation', async () => { + const harness = runtimeHarness(backRuntimeOperationFacts({ back: available }).back); + + const resolved = await resolveBoundBackRuntime({ + device: appleDevice, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.inspectFacts).toHaveBeenCalledWith(appleDevice); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledWith(appleDevice, backRuntimeUse); + expect(await resolved.execute(backExecutionParams())).toEqual({ + action: 'back', + mode: 'in-app', + message: 'Back', + }); + expect(harness.back).toHaveBeenCalledTimes(1); +}); + +test('forwards the requested back mode from the resolved dispatch context', async () => { + const harness = runtimeHarness(); + + const resolved = await resolveBoundBackRuntime({ + device: appleDevice, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + const result = await resolved.execute(backExecutionParams({ backMode: 'system' })); + + expect(result).toEqual({ action: 'back', mode: 'system', message: 'Back' }); + expect(harness.back).toHaveBeenCalledWith(expect.objectContaining({ mode: 'system' })); +}); + +test('rejects an unavailable exact-owner fact before binding', async () => { + const harness = runtimeHarness(unavailable); + + const resolved = await resolveBoundBackRuntime({ + device: appleDevice, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved).toEqual({ + ok: false, + response: { + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'back is not supported on this device', + hint: unavailable.hint, + }, + }, + }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('request router joins back admission to execution, recording, and ref invalidation', async () => { + const harness = runtimeHarness(); + const sessionStore = makeSessionStore('agent-device-back-generic-'); + const session = makeSession('back-runtime', { device: appleDevice }); + activateCompleteRefFrame(session); + sessionStore.set(session.name, session); + const handler = createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 't', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: harness.gateway, + trackDownloadableArtifact: () => 'artifact', + }); + + const response = await handler({ + command: 'back', + positionals: [], + token: 't', + session: session.name, + flags: {}, + meta: { requestId: 'back-router-join' }, + }); + + expect(response).toMatchObject({ + ok: true, + data: { action: 'back', mode: 'in-app', message: 'Back' }, + }); + expect(session.refFrameState).toBe('expired'); + expect(session.actions.at(-1)).toMatchObject({ command: 'back' }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bind).toHaveBeenCalledTimes(1); + expect(harness.back).toHaveBeenCalledTimes(1); +}); diff --git a/src/daemon/__tests__/home-runtime.test.ts b/src/daemon/__tests__/home-runtime.test.ts new file mode 100644 index 0000000000..0cf6a8a607 --- /dev/null +++ b/src/daemon/__tests__/home-runtime.test.ts @@ -0,0 +1,150 @@ +import { expect, test, vi } from 'vitest'; +import { + homeRuntimeOperationFacts, + homeRuntimeUse, + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type DeviceRuntimeGateway, + type PlatformRuntimeOperations, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform'; +import { deviceShape } from '@agent-device/kernel/device'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { activateCompleteRefFrame } from '../ref-frame.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; +import { resolveBoundHomeRuntime } from '../home-runtime.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +const macOsDevice = { + id: 'macos-host', + name: 'Mac', + platform: 'apple', + appleOs: 'macos', + kind: 'device', + target: 'desktop', + booted: true, +} as const; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf' as const, +}); + +function homeExecutionParams( + dispatchContext: GenericPlatformExecutionParams['dispatchContext'] = {}, +): GenericPlatformExecutionParams { + const session = makeSession('home-runtime', { device: macOsDevice }); + return { + session, + sessionName: session.name, + logPath: '/tmp/daemon.log', + command: 'home', + request: { command: 'home', positionals: [], token: 't', session: session.name }, + positionals: [], + out: undefined, + dispatchContext, + }; +} + +function runtimeHarness(fact: RuntimeOperationFact = available) { + const home = vi.fn(async () => undefined); + const facts: RuntimeFacts = { + device: { ...deviceShape(macOsDevice), providerMode: 'local' }, + operations: { home: fact } as RuntimeFacts['operations'], + }; + const binding = { + device: macOsDevice, + owner: localRuntimeOwner('apple'), + facts, + operations: { home }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + const bind = vi.fn(async () => binding); + const gateway: DeviceRuntimeGateway = { + inspectFacts, + bind, + shutdown: async () => {}, + }; + return { home, inspectFacts, bindDevice, bind, gateway }; +} + +test('resolves one admitted binding and drives one home navigation', async () => { + const harness = runtimeHarness(homeRuntimeOperationFacts({ home: available }).home); + + const resolved = await resolveBoundHomeRuntime({ + device: macOsDevice, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledWith(macOsDevice, homeRuntimeUse); + expect(await resolved.execute(homeExecutionParams())).toEqual({ + action: 'home', + message: 'Home', + }); + expect(harness.home).toHaveBeenCalledTimes(1); +}); + +test('rejects an unavailable exact-owner fact before binding (macOS has no springboard home)', async () => { + const harness = runtimeHarness(unavailable); + + const resolved = await resolveBoundHomeRuntime({ + device: macOsDevice, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved).toEqual({ + ok: false, + response: { + ok: false, + error: { code: 'UNSUPPORTED_OPERATION', message: 'home is not supported on this device' }, + }, + }); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('request router joins home admission to execution, recording, and ref invalidation', async () => { + const harness = runtimeHarness(); + const sessionStore = makeSessionStore('agent-device-home-generic-'); + const session = makeSession('home-runtime', { device: macOsDevice }); + activateCompleteRefFrame(session); + sessionStore.set(session.name, session); + const handler = createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 't', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: harness.gateway, + trackDownloadableArtifact: () => 'artifact', + }); + + const response = await handler({ + command: 'home', + positionals: [], + token: 't', + session: session.name, + flags: {}, + meta: { requestId: 'home-router-join' }, + }); + + expect(response).toMatchObject({ ok: true, data: { action: 'home', message: 'Home' } }); + expect(session.refFrameState).toBe('expired'); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bind).toHaveBeenCalledTimes(1); + expect(harness.home).toHaveBeenCalledTimes(1); +}); diff --git a/src/daemon/__tests__/keyboard-runtime.test.ts b/src/daemon/__tests__/keyboard-runtime.test.ts new file mode 100644 index 0000000000..5e9d5d1a5d --- /dev/null +++ b/src/daemon/__tests__/keyboard-runtime.test.ts @@ -0,0 +1,472 @@ +import { expect, test, vi } from 'vitest'; +import { + keyboardDismissUse, + keyboardEnterUse, + keyboardStatusUse, + keyboardRuntimeOperationFacts, + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type KeyboardDismissResult, + type KeyboardEnterResult, + type KeyboardStatusResult, + type PlatformRuntimeOperations, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; + +const androidDevice: DeviceInfo = { + id: 'emulator-5554', + name: 'Pixel', + platform: 'android', + kind: 'emulator', + target: 'mobile', + booted: true, +}; +const harmonyDevice: DeviceInfo = { + id: 'harmony-emulator', + name: 'nova', + platform: 'harmonyos', + kind: 'emulator', + target: 'mobile', + booted: true, +}; +const iosDevice: DeviceInfo = { + id: 'ios-simulator', + name: 'iPhone', + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + target: 'mobile', + booted: true, +}; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf' as const, + hint: 'keyboard status/get is currently supported only on Android; use keyboard dismiss or enter on iOS', +}); + +function runtimeHarness( + device: DeviceInfo, + owner: string, + facts: Readonly<{ + status: RuntimeOperationFact; + dismiss: RuntimeOperationFact; + enter: RuntimeOperationFact; + }>, +) { + const keyboardStatus = vi.fn<() => Promise>(async () => ({ + visible: true, + })); + const keyboardDismiss = vi.fn<() => Promise>(async () => ({ + dismissed: true, + visible: false, + })); + const keyboardEnter = vi.fn<() => Promise>(async () => ({})); + const runtimeFacts: RuntimeFacts = { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: keyboardRuntimeOperationFacts( + facts, + ) as RuntimeFacts['operations'], + }; + const binding = { + device, + owner: localRuntimeOwner(owner as never), + facts: runtimeFacts, + operations: { keyboardStatus, keyboardDismiss, keyboardEnter }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => runtimeFacts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + return { keyboardStatus, keyboardDismiss, keyboardEnter, inspectFacts, bindDevice }; +} + +test('android status admits keyboardStatusUse and reports the platform-shaped state', async () => { + const harness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + harness.keyboardStatus.mockResolvedValue({ + visible: true, + inputType: 'text', + type: 'ime', + inputMethodPackage: 'com.google.android.inputmethod.latin', + focusedPackage: 'com.example.app', + focusedResourceId: 'com.example.app:id/field', + inputOwner: 'app', + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: ['status'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.bindDevice).toHaveBeenCalledWith(androidDevice, keyboardStatusUse); + expect(await resolved.execute({})).toEqual({ + platform: 'android', + action: 'status', + visible: true, + inputType: 'text', + type: 'ime', + inputMethodPackage: 'com.google.android.inputmethod.latin', + focusedPackage: 'com.example.app', + focusedResourceId: 'com.example.app:id/field', + inputOwner: 'app', + }); +}); + +test('`get` is an alias for `status`', async () => { + const harness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: ['get'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + expect(harness.bindDevice).toHaveBeenCalledWith(androidDevice, keyboardStatusUse); +}); + +test('`return` is an alias for `enter`', async () => { + const harness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: ['return'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + expect(harness.bindDevice).toHaveBeenCalledWith(androidDevice, keyboardEnterUse); +}); + +test('android status is refused on iOS with the retired in-handler hint', async () => { + const harness = runtimeHarness(iosDevice, 'apple', { + status: unavailable, + dismiss: available, + enter: available, + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: iosDevice, + positionals: ['status'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved).toEqual({ + ok: false, + response: { + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'keyboard status is not supported on this device', + hint: unavailable.hint, + }, + }, + }); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('iOS dismiss reports the mechanism disclosure and its own message', async () => { + const harness = runtimeHarness(iosDevice, 'apple', { + status: unavailable, + dismiss: available, + enter: available, + }); + harness.keyboardDismiss.mockResolvedValue({ + dismissed: true, + visible: false, + wasVisible: true, + mechanism: 'dismissKey', + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: iosDevice, + positionals: ['dismiss'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.bindDevice).toHaveBeenCalledWith(iosDevice, keyboardDismissUse); + expect(await resolved.execute({})).toEqual({ + platform: 'ios', + action: 'dismiss', + wasVisible: true, + dismissed: true, + visible: false, + mechanism: 'dismissKey', + message: 'Keyboard dismissed via its dismiss key', + }); +}); + +// #1598: the response must disclose which mechanism the runner used to resign the keyboard — +// only the keyboard's own dismiss key is a mechanism the runner vouches for; an unrecognized wire +// 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, + dismiss: available, + enter: available, + }); + harness.keyboardDismiss.mockResolvedValue({ + dismissed: true, + visible: false, + wasVisible: true, + mechanism: 'legacySafeAreaTap', + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: iosDevice, + positionals: ['dismiss'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + const result = await resolved.execute({}); + expect(result).toMatchObject({ mechanism: 'legacySafeAreaTap', message: 'Keyboard dismissed' }); +}); + +test('iOS dismiss omits a message mechanism claim when every mechanism failed', async () => { + const harness = runtimeHarness(iosDevice, 'apple', { + status: unavailable, + dismiss: available, + enter: available, + }); + harness.keyboardDismiss.mockResolvedValue({ + dismissed: false, + visible: true, + wasVisible: true, + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: iosDevice, + positionals: ['dismiss'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + const result = await resolved.execute({}); + expect(result).toMatchObject({ + dismissed: false, + mechanism: undefined, + message: 'Keyboard already hidden', + }); +}); + +test('iOS dismiss omits a mechanism claim when the keyboard was never visible', async () => { + const harness = runtimeHarness(iosDevice, 'apple', { + status: unavailable, + dismiss: available, + enter: available, + }); + harness.keyboardDismiss.mockResolvedValue({ + dismissed: false, + visible: false, + wasVisible: false, + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: iosDevice, + positionals: ['dismiss'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + const result = await resolved.execute({}); + expect(result).toMatchObject({ + wasVisible: false, + mechanism: undefined, + message: 'Keyboard already hidden', + }); +}); + +test('harmonyos dismiss reports success with no structured fields beyond the message', async () => { + const harness = runtimeHarness(harmonyDevice, 'harmonyos', { + status: unavailable, + dismiss: available, + enter: available, + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: harmonyDevice, + positionals: ['dismiss'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(await resolved.execute({})).toEqual({ + platform: 'harmonyos', + action: 'dismiss', + message: 'Keyboard dismissed', + }); +}); + +test('android dismiss reports the full IME probe evidence', async () => { + const harness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + harness.keyboardDismiss.mockResolvedValue({ + attempts: 2, + wasVisible: true, + dismissed: true, + visible: false, + inputType: 'text', + type: 'ime', + inputMethodPackage: 'com.google.android.inputmethod.latin', + focusedPackage: 'com.example.app', + focusedResourceId: 'com.example.app:id/field', + inputOwner: 'app', + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: ['dismiss'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(await resolved.execute({})).toEqual({ + platform: 'android', + action: 'dismiss', + attempts: 2, + wasVisible: true, + dismissed: true, + visible: false, + inputType: 'text', + type: 'ime', + inputMethodPackage: 'com.google.android.inputmethod.latin', + focusedPackage: 'com.example.app', + focusedResourceId: 'com.example.app:id/field', + inputOwner: 'app', + }); +}); + +test('iOS enter reports visibility evidence; android enter reports only success', async () => { + const iosHarness = runtimeHarness(iosDevice, 'apple', { + status: unavailable, + dismiss: available, + enter: available, + }); + iosHarness.keyboardEnter.mockResolvedValue({ visible: false, wasVisible: true }); + const iosResolved = await resolveBoundKeyboardRuntime({ + device: iosDevice, + positionals: ['enter'], + inspectFacts: iosHarness.inspectFacts, + bindDevice: iosHarness.bindDevice, + }); + expect(iosResolved.ok).toBe(true); + if (iosResolved.ok) { + expect(await iosResolved.execute({})).toEqual({ + platform: 'ios', + action: 'enter', + visible: false, + wasVisible: true, + message: 'Keyboard enter pressed', + }); + } + + const androidHarness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + const androidResolved = await resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: ['enter'], + inspectFacts: androidHarness.inspectFacts, + bindDevice: androidHarness.bindDevice, + }); + expect(androidResolved.ok).toBe(true); + if (androidResolved.ok) { + expect(await androidResolved.execute({})).toEqual({ + platform: 'android', + action: 'enter', + message: 'Keyboard enter pressed', + }); + } +}); + +test('rejects an unknown subcommand before inspection or binding', async () => { + const harness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + + await expect( + resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: ['sideways'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('rejects more than one subcommand argument', async () => { + const harness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + + await expect( + resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: ['dismiss', 'extra'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); +}); + +test('defaults to status with no positional', async () => { + const harness = runtimeHarness(androidDevice, 'android', { + status: available, + dismiss: available, + enter: available, + }); + + const resolved = await resolveBoundKeyboardRuntime({ + device: androidDevice, + positionals: [], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + expect(harness.bindDevice).toHaveBeenCalledWith(androidDevice, keyboardStatusUse); +}); diff --git a/src/daemon/__tests__/orientation-runtime.test.ts b/src/daemon/__tests__/orientation-runtime.test.ts new file mode 100644 index 0000000000..4cff472323 --- /dev/null +++ b/src/daemon/__tests__/orientation-runtime.test.ts @@ -0,0 +1,213 @@ +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + orientationRuntimeOperationFacts, + orientationRuntimeUse, + type DeviceBinding, + type DeviceRuntimeGateway, + type PlatformRuntimeOperations, + type RuntimeFacts, + type RuntimeOperationFact, + type SetOrientationResult, +} from '@agent-device/contracts/platform'; +import { deviceShape } from '@agent-device/kernel/device'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { activateCompleteRefFrame } from '../ref-frame.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; +import { + readRequestedOrientation, + resolveBoundOrientationRuntime, +} from '../orientation-runtime.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +const androidDevice = { + id: 'emulator-5554', + name: 'Pixel', + platform: 'android', + kind: 'emulator', + target: 'mobile', + booted: true, +} as const; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf' as const, +}); + +function orientationExecutionParams( + positionals: string[], + dispatchContext: GenericPlatformExecutionParams['dispatchContext'] = {}, +): GenericPlatformExecutionParams { + const session = makeSession('orientation-runtime', { device: androidDevice }); + return { + session, + sessionName: session.name, + logPath: '/tmp/daemon.log', + command: 'orientation', + request: { command: 'orientation', positionals, token: 't', session: session.name }, + positionals, + out: undefined, + dispatchContext, + }; +} + +function runtimeHarness( + fact: RuntimeOperationFact = available, + setOrientation = vi.fn<() => Promise>(async () => undefined), +) { + const facts: RuntimeFacts = { + device: { ...deviceShape(androidDevice), providerMode: 'local' }, + operations: { setOrientation: fact } as RuntimeFacts['operations'], + }; + const binding = { + device: androidDevice, + owner: localRuntimeOwner('android'), + facts, + operations: { setOrientation }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + const bind = vi.fn(async () => binding); + const gateway: DeviceRuntimeGateway = { + inspectFacts, + bind, + shutdown: async () => {}, + }; + return { setOrientation, inspectFacts, bindDevice, bind, gateway }; +} + +test('parses the requested rotation exactly as the retired leaf did, including its aliases', () => { + expect(readRequestedOrientation(['landscape-left'])).toBe('landscape-left'); + expect(readRequestedOrientation(['left'])).toBe('landscape-left'); + expect(() => readRequestedOrientation(['sideways'])).toThrow(); +}); + +test('resolves one admitted binding and reports the owner-observed rotation', async () => { + const setOrientation = vi.fn(async () => ({ orientation: 'landscape-left' as const })); + const harness = runtimeHarness( + orientationRuntimeOperationFacts({ orientation: available }).setOrientation, + setOrientation, + ); + + const resolved = await resolveBoundOrientationRuntime({ + device: androidDevice, + positionals: ['landscape-left'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.bindDevice).toHaveBeenCalledWith(androidDevice, orientationRuntimeUse); + expect(await resolved.execute(orientationExecutionParams(['landscape-left']))).toEqual({ + action: 'orientation', + orientation: 'landscape-left', + message: 'Rotated to landscape-left', + }); +}); + +test('falls back to the requested rotation when the owner reports nothing', async () => { + const harness = runtimeHarness(); + + const resolved = await resolveBoundOrientationRuntime({ + device: androidDevice, + positionals: ['portrait'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(await resolved.execute(orientationExecutionParams(['portrait']))).toEqual({ + action: 'orientation', + orientation: 'portrait', + message: 'Rotated to portrait', + }); +}); + +test('rejects an invalid rotation before inspection or binding', async () => { + const harness = runtimeHarness(); + + await expect( + resolveBoundOrientationRuntime({ + device: androidDevice, + positionals: ['sideways'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toThrow(); + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('rejects an unavailable exact-owner fact before binding', async () => { + const harness = runtimeHarness(unavailable); + + const resolved = await resolveBoundOrientationRuntime({ + device: androidDevice, + positionals: ['landscape-left'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved).toEqual({ + ok: false, + response: { + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'orientation is not supported on this device', + }, + }, + }); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('request router joins orientation admission to execution and ref invalidation', async () => { + const setOrientation = vi.fn(async () => ({ orientation: 'landscape-left' as const })); + const harness = runtimeHarness( + orientationRuntimeOperationFacts({ orientation: available }).setOrientation, + setOrientation, + ); + const sessionStore = makeSessionStore('agent-device-orientation-generic-'); + const session = makeSession('orientation-runtime', { device: androidDevice }); + activateCompleteRefFrame(session); + sessionStore.set(session.name, session); + const handler = createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 't', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: harness.gateway, + trackDownloadableArtifact: () => 'artifact', + }); + + const response = await handler({ + command: 'orientation', + positionals: ['landscape-left'], + token: 't', + session: session.name, + flags: {}, + meta: { requestId: 'orientation-router-join' }, + }); + + expect(response).toMatchObject({ + ok: true, + data: { + action: 'orientation', + orientation: 'landscape-left', + message: 'Rotated to landscape-left', + }, + }); + expect(session.refFrameState).toBe('expired'); + expect(harness.bind).toHaveBeenCalledTimes(1); + expect(setOrientation).toHaveBeenCalledTimes(1); +}); diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index 310a11f3c7..59c38deb09 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -29,7 +29,7 @@ const mockDispatch = vi.mocked(dispatchCommand); // A representative, structurally rich daemon payload so the parity assertions // exercise nested objects/arrays rather than a trivial flat record. const REPRESENTATIVE_PAYLOAD = { - message: 'home-ok', + message: 'app-switcher-ok', detail: { nested: true, count: 3 }, items: [1, 2, 3], } as const; @@ -69,7 +69,7 @@ function baseRequest(overrides: Partial = {}): DaemonRequest { return { token: 'test-token', session: 'cost-session', - command: 'home', + command: 'app-switcher', positionals: [], flags: {}, ...overrides, @@ -208,14 +208,14 @@ test('(d) error path: a failing request with includeCost:true produces NO cost', test('(e) boundary survival: meta.includeCost survives commandRpcParamsSchema parsing', () => { const parsed = commandRpcParamsSchema.parse({ - command: 'home', + command: 'app-switcher', positionals: [], meta: { includeCost: true }, }); expect(parsed.meta?.includeCost).toBe(true); const parsedOff = commandRpcParamsSchema.parse({ - command: 'home', + command: 'app-switcher', positionals: [], meta: {}, }); diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index f33551ba4c..bc45c50786 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -521,7 +521,7 @@ test('direct daemon requests apply strip lock policy for existing sessions befor const response = await handler({ token: 'test-token', session: 'qa-ios', - command: 'home', + command: 'app-switcher', positionals: [], flags: { target: 'tv', @@ -608,7 +608,7 @@ test('batch preserves tenant-scoped session names across nested requests', async command: 'batch', positionals: [], flags: { - batchSteps: [{ command: 'home' }], + batchSteps: [{ command: 'app-switcher' }], }, meta: { tenantId: 'tenant-a', @@ -620,5 +620,5 @@ test('batch preserves tenant-scoped session names across nested requests', async expect(response.ok).toBe(true); expect(dispatchCalls).toBe(1); - expect(sessionStore.get('tenant-a:default')?.actions.at(-1)?.command).toBe('home'); + expect(sessionStore.get('tenant-a:default')?.actions.at(-1)?.command).toBe('app-switcher'); }); diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index 9a8cb1beab..e965159d6d 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -60,7 +60,7 @@ beforeEach(() => { test('replay runs active-session actions inside the parent request provider scope', async () => { const root = mkdtempForTestSync('agent-device-replay-scope-'); const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'home\nback\n'); + fs.writeFileSync(replayPath, 'app-switcher\nscroll down\n'); const sessionStore = makeSessionStore('agent-device-replay-scope-'); sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const appleRunnerProvider = vi.fn(() => undefined); @@ -92,7 +92,7 @@ test('replay runs active-session actions inside the parent request provider scop test('replay routes session-changing actions through the full request path', async () => { const root = mkdtempForTestSync('agent-device-replay-full-route-'); const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'runtime set --platform ios --metro-host localhost\nhome\n'); + fs.writeFileSync(replayPath, 'runtime set --platform ios --metro-host localhost\napp-switcher\n'); const sessionStore = makeSessionStore('agent-device-replay-full-route-'); sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const appleRunnerProvider = vi.fn(() => undefined); diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index 2c6f151aac..8d99c58c22 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -26,8 +26,10 @@ vi.mock('../response-views.ts', async (importOriginal) => { ...actual, RESPONSE_VIEWS: { ...actual.RESPONSE_VIEWS, - home: (data: Record, level: string) => - level === 'digest' ? { homeDigest: true, hadItems: Array.isArray(data.items) } : data, + 'app-switcher': (data: Record, level: string) => + level === 'digest' + ? { appSwitcherDigest: true, hadItems: Array.isArray(data.items) } + : data, }, }; }); @@ -41,7 +43,7 @@ import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; const mockDispatch = vi.mocked(dispatchCommand); -const REPRESENTATIVE_PAYLOAD = { message: 'home-ok', items: [1, 2, 3] } as const; +const REPRESENTATIVE_PAYLOAD = { message: 'app-switcher-ok', items: [1, 2, 3] } as const; function makeIosSession(name: string): SessionState { return { @@ -94,9 +96,11 @@ beforeEach(() => { test('(a) default identity: responseLevel absent === default === no meta, byte-identical', async () => { const { handler } = makeHandler(); - const noMeta = await handler(request('home')); - const emptyMeta = await handler(request('home', { meta: {} })); - const explicitDefault = await handler(request('home', { meta: { responseLevel: 'default' } })); + const noMeta = await handler(request('app-switcher')); + const emptyMeta = await handler(request('app-switcher', { meta: {} })); + const explicitDefault = await handler( + request('app-switcher', { meta: { responseLevel: 'default' } }), + ); expect(JSON.stringify(noMeta)).toBe(JSON.stringify(emptyMeta)); expect(JSON.stringify(noMeta)).toBe(JSON.stringify(explicitDefault)); @@ -105,35 +109,35 @@ test('(a) default identity: responseLevel absent === default === no meta, byte-i test('(b) digest applies the registered view, dropping the full payload', async () => { const { handler } = makeHandler(); - const resp = await handler(request('home', { meta: { responseLevel: 'digest' } })); + const resp = await handler(request('app-switcher', { meta: { responseLevel: 'digest' } })); expect(resp.ok).toBe(true); if (!resp.ok) return; - expect(resp.data).toEqual({ homeDigest: true, hadItems: true }); + expect(resp.data).toEqual({ appSwitcherDigest: true, hadItems: true }); expect('message' in (resp.data ?? {})).toBe(false); }); test('(c) full returns today’s shape (view passthrough) — byte-identical to default', async () => { const { handler } = makeHandler(); - const full = await handler(request('home', { meta: { responseLevel: 'full' } })); - const def = await handler(request('home', { meta: { responseLevel: 'default' } })); + const full = await handler(request('app-switcher', { meta: { responseLevel: 'full' } })); + const def = await handler(request('app-switcher', { meta: { responseLevel: 'default' } })); expect(JSON.stringify(full)).toBe(JSON.stringify(def)); }); test('(d) digest composes with --cost: viewed data plus an additive cost block', async () => { const { handler } = makeHandler(); const resp = await handler( - request('home', { meta: { responseLevel: 'digest', includeCost: true } }), + request('app-switcher', { meta: { responseLevel: 'digest', includeCost: true } }), ); expect(resp.ok).toBe(true); if (!resp.ok) return; - expect(resp.data).toMatchObject({ homeDigest: true, hadItems: true }); + expect(resp.data).toMatchObject({ appSwitcherDigest: true, hadItems: true }); expect(typeof resp.data?.cost?.wallClockMs).toBe('number'); }); test('(e) digest on a command with no registered view is byte-identical to default', async () => { const { handler } = makeHandler(); - const digest = await handler(request('back', { meta: { responseLevel: 'digest' } })); - const def = await handler(request('back', { meta: {} })); + const digest = await handler(request('scroll', { meta: { responseLevel: 'digest' } })); + const def = await handler(request('scroll', { meta: {} })); expect(JSON.stringify(digest)).toBe(JSON.stringify(def)); if (digest.ok) expect(digest.data).toEqual(REPRESENTATIVE_PAYLOAD); }); diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index bbe3f8ab10..2aac875041 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -103,13 +103,13 @@ test('UNSUPPORTED_OPERATION errors carry supportedOn derived from the capability sessionStore.set('typed-error', makeIosSession('typed-error')); mockDispatch.mockRejectedValue(new AppError('UNSUPPORTED_OPERATION', 'nope on this platform')); - // `home` routes through the (mocked) generic dispatch and is platform-restricted. - const response = await handler(request('home')); + // `app-switcher` routes through the (mocked) generic dispatch and is platform-restricted. + const response = await handler(request('app-switcher')); expect(response.ok).toBe(false); if (response.ok) return; - const expected = supportedPlatformsForCommand('home'); - expect(expected.length).toBeGreaterThan(0); // home is a platform-restricted command + const expected = supportedPlatformsForCommand('app-switcher'); + expect(expected.length).toBeGreaterThan(0); // app-switcher is a platform-restricted command expect(response.error.supportedOn).toBe(expected.join(', ')); }); @@ -118,7 +118,7 @@ test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', asy sessionStore.set('typed-error', makeIosSession('typed-error')); mockDispatch.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy')); - const response = await handler(request('home')); + const response = await handler(request('app-switcher')); expect(response.ok).toBe(false); if (response.ok) return; @@ -133,7 +133,7 @@ test('deterministic errors (INVALID_ARGS) are returned with the default shape // Conflicting explicit selector under a reject lock policy fails with INVALID_ARGS // before dispatch — a deterministic error. const response = await handler( - request('home', { flags: { udid: 'SIM-999' }, meta: { lockPolicy: 'reject' } }), + request('app-switcher', { flags: { udid: 'SIM-999' }, meta: { lockPolicy: 'reject' } }), ); expect(response.ok).toBe(false); diff --git a/src/daemon/__tests__/tv-remote-runtime.test.ts b/src/daemon/__tests__/tv-remote-runtime.test.ts new file mode 100644 index 0000000000..9321a0e67c --- /dev/null +++ b/src/daemon/__tests__/tv-remote-runtime.test.ts @@ -0,0 +1,214 @@ +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + tvRemoteRuntimeOperationFacts, + tvRemoteRuntimeUse, + type DeviceBinding, + type DeviceRuntimeGateway, + type PlatformRuntimeOperations, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform'; +import { deviceShape } from '@agent-device/kernel/device'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { activateCompleteRefFrame } from '../ref-frame.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; +import { resolveBoundTvRemoteRuntime } from '../tv-remote-runtime.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +const vegaVvd = { + id: 'vega-vvd', + name: 'Vega VVD', + platform: 'vega', + kind: 'emulator', + target: 'tv', + booted: true, +} as const; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind' as const, + hint: 'tv-remote currently supports only Vega Virtual Devices.', +}); + +function tvRemoteExecutionParams( + positionals: string[], + dispatchContext: GenericPlatformExecutionParams['dispatchContext'] = {}, +): GenericPlatformExecutionParams { + const session = makeSession('tv-remote-runtime', { device: vegaVvd }); + return { + session, + sessionName: session.name, + logPath: '/tmp/daemon.log', + command: 'tv-remote', + request: { command: 'tv-remote', positionals, token: 't', session: session.name }, + positionals, + out: undefined, + dispatchContext, + }; +} + +function runtimeHarness(fact: RuntimeOperationFact = available) { + const tvRemote = vi.fn(async () => undefined); + const facts: RuntimeFacts = { + device: { ...deviceShape(vegaVvd), providerMode: 'local' }, + operations: { tvRemote: fact } as RuntimeFacts['operations'], + }; + const binding = { + device: vegaVvd, + owner: localRuntimeOwner('vega'), + facts, + operations: { tvRemote }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + const bind = vi.fn(async () => binding); + const gateway: DeviceRuntimeGateway = { + inspectFacts, + bind, + shutdown: async () => {}, + }; + return { tvRemote, inspectFacts, bindDevice, bind, gateway }; +} + +test('resolves one admitted binding and presses one remote button', async () => { + const harness = runtimeHarness(tvRemoteRuntimeOperationFacts({ tvRemote: available }).tvRemote); + + const resolved = await resolveBoundTvRemoteRuntime({ + device: vegaVvd, + positionals: ['down'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.bindDevice).toHaveBeenCalledWith(vegaVvd, tvRemoteRuntimeUse); + expect(await resolved.execute(tvRemoteExecutionParams(['down']))).toEqual({ + action: 'tv-remote', + button: 'down', + message: 'Pressed TV remote down', + }); + expect(harness.tvRemote).toHaveBeenCalledWith( + expect.objectContaining({ button: 'down', durationMs: undefined }), + ); +}); + +test('forwards a validated duration and reports it in the response', async () => { + const harness = runtimeHarness(); + + const resolved = await resolveBoundTvRemoteRuntime({ + device: vegaVvd, + positionals: ['select'], + durationMs: 500, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(await resolved.execute(tvRemoteExecutionParams(['select']))).toEqual({ + action: 'tv-remote', + button: 'select', + durationMs: 500, + message: 'Pressed TV remote select', + }); + expect(harness.tvRemote).toHaveBeenCalledWith( + expect.objectContaining({ button: 'select', durationMs: 500 }), + ); +}); + +test('rejects an out-of-range duration before inspection or binding', async () => { + const harness = runtimeHarness(); + + await expect( + resolveBoundTvRemoteRuntime({ + device: vegaVvd, + positionals: ['down'], + durationMs: 50_000, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toThrow(); + expect(harness.inspectFacts).not.toHaveBeenCalled(); +}); + +test('rejects a missing button before inspection or binding', async () => { + const harness = runtimeHarness(); + + await expect( + resolveBoundTvRemoteRuntime({ + device: vegaVvd, + positionals: [], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('rejects an unavailable exact-owner fact before binding', async () => { + const harness = runtimeHarness(unavailable); + + const resolved = await resolveBoundTvRemoteRuntime({ + device: vegaVvd, + positionals: ['down'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved).toEqual({ + ok: false, + response: { + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'tv-remote is not supported on this device', + hint: unavailable.hint, + }, + }, + }); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('request router joins tv-remote admission to execution and ref invalidation', async () => { + const harness = runtimeHarness(); + const sessionStore = makeSessionStore('agent-device-tv-remote-generic-'); + const session = makeSession('tv-remote-runtime', { device: vegaVvd }); + activateCompleteRefFrame(session); + sessionStore.set(session.name, session); + const handler = createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 't', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: harness.gateway, + trackDownloadableArtifact: () => 'artifact', + }); + + const response = await handler({ + command: 'tv-remote', + positionals: ['down'], + token: 't', + session: session.name, + flags: {}, + meta: { requestId: 'tv-remote-router-join' }, + }); + + expect(response).toMatchObject({ + ok: true, + data: { action: 'tv-remote', button: 'down', message: 'Pressed TV remote down' }, + }); + expect(session.refFrameState).toBe('expired'); + expect(harness.bind).toHaveBeenCalledTimes(1); + expect(harness.tvRemote).toHaveBeenCalledTimes(1); +}); diff --git a/src/daemon/back-runtime.ts b/src/daemon/back-runtime.ts new file mode 100644 index 0000000000..6fc471e817 --- /dev/null +++ b/src/daemon/back-runtime.ts @@ -0,0 +1,52 @@ +import { backRuntimeUse, type BackInput } from '@agent-device/contracts/platform'; +import type { BackMode } from '@agent-device/contracts/interaction'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; + +/** The neutral intent one `back` carries, projected from a resolved command context. */ +function backInput(mode: BackMode | undefined, context: DaemonCommandContext): BackInput { + return { + mode, + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place `back` reaches a device (ADR 0019). Admission inspects the exact owner's `back` + * fact and binds once, before the dispatcher runs, so an owner that cannot navigate back is + * refused rather than discovered mid-execution. + */ +export async function resolveBoundBackRuntime( + params: { + device: DeviceInfo; + } & RuntimeAdmissionBindings, +): Promise { + return await resolveBoundGenericRuntime( + { + command: 'back', + device: params.device, + use: backRuntimeUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }, + executeBack, + ); +} + +/** The ONE place a bound `back` executes (R42). */ +async function executeBack( + runtime: Readonly<{ operations: Readonly<{ back: (input: BackInput) => Promise }> }>, + context: DaemonCommandContext, +): Promise> { + await runtime.operations.back(backInput(context.backMode, context)); + return { + action: 'back', + mode: context.backMode ?? 'in-app', + ...successText('Back'), + }; +} diff --git a/src/daemon/focus-runtime.ts b/src/daemon/focus-runtime.ts index 80aca77d1f..1db614210d 100644 --- a/src/daemon/focus-runtime.ts +++ b/src/daemon/focus-runtime.ts @@ -5,7 +5,7 @@ import { successText } from '../utils/success-text.ts'; import { readPointPositionals } from '../utils/validation.ts'; import type { DaemonCommandContext } from './context.ts'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; -import { admitRuntimeUse, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; /** `focus x y`, on the same positional parse its still-legacy touch siblings take. */ @@ -34,20 +34,16 @@ export async function resolveBoundFocusRuntime( } & RuntimeAdmissionBindings, ): Promise { const point = readFocusPoint(params.positionals); - const admission = await admitRuntimeUse({ - command: 'focus', - device: params.device, - use: focusRuntimeUse, - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, - }); - if (admission.type === 'response') return { ok: false, response: admission.response }; - const runtime = admission.runtime; - return { - ok: true, - execute: async ({ dispatchContext }) => - await executeFocusPoint(runtime, point, dispatchContext), - }; + return await resolveBoundGenericRuntime( + { + command: 'focus', + device: params.device, + use: focusRuntimeUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }, + (runtime, context) => executeFocusPoint(runtime, point, context), + ); } /** diff --git a/src/daemon/generic-runtime-execution.ts b/src/daemon/generic-runtime-execution.ts index 9c207a7c96..15c5c0d404 100644 --- a/src/daemon/generic-runtime-execution.ts +++ b/src/daemon/generic-runtime-execution.ts @@ -4,6 +4,10 @@ import { resolveScreenshotGenericExecution } from './screenshot-runtime.ts'; import type { ScreenshotRuntimeBindings } from './screenshot-runtime-binding.ts'; import type { DaemonRequest, SessionState } from './types.ts'; import { resolveBoundViewportRuntime } from './viewport-runtime.ts'; +import { resolveBoundBackRuntime } from './back-runtime.ts'; +import { resolveBoundHomeRuntime } from './home-runtime.ts'; +import { resolveBoundOrientationRuntime } from './orientation-runtime.ts'; +import { resolveBoundTvRemoteRuntime } from './tv-remote-runtime.ts'; /** * The generic route's runtime-owned leaves (ADR 0019). Each one admits its own exact owner facts @@ -30,6 +34,33 @@ export async function resolveGenericRuntimeExecution( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }); + case 'back': + return await resolveBoundBackRuntime({ + device: params.session.device, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + case 'home': + return await resolveBoundHomeRuntime({ + device: params.session.device, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + case 'orientation': + return await resolveBoundOrientationRuntime({ + device: params.session.device, + positionals: params.req.positionals ?? [], + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + case 'tv-remote': + return await resolveBoundTvRemoteRuntime({ + device: params.session.device, + positionals: params.req.positionals ?? [], + durationMs: params.req.flags?.durationMs, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); default: return undefined; } diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 910f8f4e24..f2669bd55b 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -356,6 +356,13 @@ function sourceRuntimeFacts( focusPoint: unavailable, typeText: unavailable, readTextAtPoint: unavailable, + back: unavailable, + home: unavailable, + setOrientation: unavailable, + tvRemote: unavailable, + keyboardStatus: unavailable, + keyboardDismiss: unavailable, + keyboardEnter: unavailable, deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts index 3140a1e665..e48a1ed44a 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -88,6 +88,13 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts = { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: keyboardRuntimeOperationFacts({ + status: available, + dismiss: available, + enter: available, + }) as RuntimeFacts['operations'], + }; + const binding = { + device, + owner: localRuntimeOwner(device.platform), + facts, + operations: { + keyboardStatus: async () => ({ visible: false }), + keyboardDismiss: async () => ({ dismissed: true, visible: false }), + keyboardEnter: async () => ({}), + }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + return { inspectFacts, bindDevice }; +} + test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; @@ -168,6 +211,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves mockResolveTargetDevice.mockResolvedValue(device); mockDispatch.mockResolvedValue({}); const logPath = path.join(os.tmpdir(), 'daemon.log'); + const { inspectFacts, bindDevice } = keyboardCapableRuntime(device); // dismiss mutates the device → frame expires. sessionStore.set(sessionName, makeSession(sessionName, device)); @@ -183,6 +227,8 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves logPath, sessionStore, invoke: noopInvoke, + inspectFacts, + bindDevice, }); expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); @@ -200,6 +246,8 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves logPath, sessionStore, invoke: noopInvoke, + inspectFacts, + bindDevice, }); expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); }); diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index 6b646a26c3..86da3f006a 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts @@ -91,6 +91,13 @@ function createAdmissionFacts( setViewport: unavailable, focusPoint: unavailable, typeText: unavailable, + back: unavailable, + home: unavailable, + setOrientation: unavailable, + tvRemote: unavailable, + keyboardStatus: unavailable, + keyboardDismiss: unavailable, + keyboardEnter: unavailable, deployApp: cell(options.deployAvailable), materializeAppSource: cell(options.sourceAvailable), deployMaterializedApp: cell(options.sourceAvailable), diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index aa4684886b..5ecea2cc7e 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -148,6 +148,13 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts { focus: { available: false, reason: 'owner-capability-missing' }, typeText: { available: false, reason: 'owner-capability-missing' }, elementText: { available: false, reason: 'owner-capability-missing' }, + back: { available: false, reason: 'owner-capability-missing' }, + 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' }, readiness: { available: false, reason: 'unsupported-device-kind' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, @@ -137,6 +144,13 @@ test('appstate rejects web before Android app-state backend dispatch', async () focus: { available: false, reason: 'unsupported-platform-leaf' }, typeText: { available: false, reason: 'unsupported-platform-leaf' }, elementText: { available: false, reason: 'unsupported-platform-leaf' }, + back: { available: false, reason: 'unsupported-platform-leaf' }, + 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' }, readiness: { available: false, reason: 'unsupported-platform-leaf' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 472d211e29..e49c3790e0 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -10,6 +10,7 @@ import { requireSessionOrExplicitSelector, resolveCommandDevice } from './sessio import { errorResponse, requireCommandSupported } from './response.ts'; import { recordSessionAction } from './handler-utils.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; +import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; import { requireRuntimeBinding, requireRuntimeFacts } from './session-runtime-admission.ts'; import { handleOpenCommand } from './session-open.ts'; import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts'; @@ -252,29 +253,83 @@ const handleSessionReplayCommandGroup: SessionCommandHandler = async ({ throwIfCanceled, }); -async function handleKeyboardCommand(params: SessionCommandParams): Promise { - const { req, sessionName, logPath, sessionStore } = params; - const session = sessionStore.get(sessionName); - const keyboardAction = req.positionals?.[0]?.trim().toLowerCase(); +/** + * A dismiss/enter/return sent with no session and no explicit iOS selector would target whatever + * app happens to be foreground on a later-resolved device, silently. Refuse it up front rather + * than let admission and binding run against a target the caller never named. + */ +function requireForegroundIosKeyboardSession( + session: SessionState | undefined, + keyboardAction: string | undefined, + flags: DaemonRequest['flags'], +): DaemonResponse | undefined { const needsForegroundIosApp = keyboardAction === 'dismiss' || keyboardAction === 'enter' || keyboardAction === 'return'; - if (!session && needsForegroundIosApp) { - const flags = req.flags ?? {}; - const normalizedPlatform = flags.platform; - if (normalizedPlatform === 'ios') { - return errorResponse( - 'SESSION_NOT_FOUND', - 'iOS keyboard action requires an active session so the target app stays foregrounded. Run open first.', - ); - } + if (session || !needsForegroundIosApp || flags?.platform !== 'ios') return undefined; + return errorResponse( + 'SESSION_NOT_FOUND', + 'iOS keyboard action requires an active session so the target app stays foregrounded. Run open first.', + ); +} + +async function executeBoundKeyboardCommand(params: { + bound: Extract>, { ok: true }>; + req: DaemonRequest; + session: SessionState | undefined; + logPath: string; + sessionStore: SessionStore; + positionals: string[]; +}): Promise { + const { bound, req, session, logPath, sessionStore, positionals } = params; + const dispatchContext = { + ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + surface: session?.surface, + }; + const result = await bound.execute(dispatchContext); + if (session) { + recordSessionAction(sessionStore, session, req, PUBLIC_COMMANDS.keyboard, result ?? {}, { + positionals, + }); } - return await runSessionOrSelectorDispatch({ + return { ok: true, data: result ?? {} }; +} + +async function handleKeyboardCommand(params: SessionCommandParams): Promise { + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const session = sessionStore.get(sessionName); + const positionals = req.positionals ?? []; + const flags = req.flags ?? {}; + + const foregroundGuard = requireForegroundIosKeyboardSession( + session, + positionals[0]?.trim().toLowerCase(), + flags, + ); + if (foregroundGuard) return foregroundGuard; + + const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.keyboard, session, flags); + if (guard) return guard; + + const device = await resolveCommandDevice({ session, flags, ensureReady: true }); + const bound = await resolveBoundKeyboardRuntime({ + device, + positionals, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return bound.response; + + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } + + return await executeBoundKeyboardCommand({ + bound, req, - sessionName, + session, logPath, sessionStore, - command: PUBLIC_COMMANDS.keyboard, - positionals: req.positionals ?? [], + positionals, }); } diff --git a/src/daemon/home-runtime.ts b/src/daemon/home-runtime.ts new file mode 100644 index 0000000000..cc7c270af7 --- /dev/null +++ b/src/daemon/home-runtime.ts @@ -0,0 +1,46 @@ +import { homeRuntimeUse, type HomeInput } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; + +/** The neutral intent one `home` carries, projected from a resolved command context. */ +function homeInput(context: DaemonCommandContext): HomeInput { + return { + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place `home` reaches a device (ADR 0019). Admission inspects the exact owner's `home` + * fact and binds once, before the dispatcher runs, so an owner that cannot navigate home is + * refused rather than discovered mid-execution. + */ +export async function resolveBoundHomeRuntime( + params: { + device: DeviceInfo; + } & RuntimeAdmissionBindings, +): Promise { + return await resolveBoundGenericRuntime( + { + command: 'home', + device: params.device, + use: homeRuntimeUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }, + executeHome, + ); +} + +/** The ONE place a bound `home` executes (R43). */ +async function executeHome( + runtime: Readonly<{ operations: Readonly<{ home: (input: HomeInput) => Promise }> }>, + context: DaemonCommandContext, +): Promise> { + await runtime.operations.home(homeInput(context)); + return { action: 'home', ...successText('Home') }; +} diff --git a/src/daemon/keyboard-runtime.ts b/src/daemon/keyboard-runtime.ts new file mode 100644 index 0000000000..98d84563b9 --- /dev/null +++ b/src/daemon/keyboard-runtime.ts @@ -0,0 +1,222 @@ +import { + keyboardDismissUse, + keyboardEnterUse, + keyboardStatusUse, + type KeyboardActionInput, + type KeyboardDismissResult, + type KeyboardEnterResult, + type KeyboardStatusResult, +} from '@agent-device/contracts/platform'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { isKeyboardAction, type KeyboardAction } from '../utils/keyboard-actions.ts'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import { admitRuntimeUse, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; +import type { DaemonFailureResponse } from './handlers/response.ts'; + +type KeyboardRuntimeAction = 'status' | 'dismiss' | 'enter'; + +/** + * `keyboard`'s resolve/execute split (mirrors {@link ResolvedGenericExecution} from + * `request-generic-dispatch.ts`), but scoped to what the session route actually has: a resolved + * command context, never a full {@link SessionState} — `keyboard status`/`dismiss` run sessionless + * through an explicit selector, so a handler that required one would be proving something false. + */ +export type ResolvedKeyboardExecution = + | Readonly<{ ok: false; response: DaemonFailureResponse }> + | Readonly<{ + ok: true; + execute: (context: DaemonCommandContext) => Promise | void>; + }>; + +/** `keyboard `, on the same parse the retired leaf used; `get`/`return` are aliases. */ +function readKeyboardAction(positionals: readonly string[]): KeyboardRuntimeAction { + const action = (positionals[0] ?? 'status').toLowerCase(); + if (!isKeyboardAction(action)) { + throw new AppError( + 'INVALID_ARGS', + 'keyboard requires a subcommand: status, get, dismiss, enter, or return', + ); + } + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'keyboard accepts at most one subcommand argument'); + } + return normalizeKeyboardAction(action); +} + +function normalizeKeyboardAction(action: KeyboardAction): KeyboardRuntimeAction { + if (action === 'get') return 'status'; + if (action === 'return') return 'enter'; + return action; +} + +/** The literal the legacy leaf reported: `harmonyos`/`android` verbatim, every iOS-family OS as `ios`. */ +function keyboardPlatformLabel(device: DeviceInfo): 'android' | 'harmonyos' | 'ios' { + if (device.platform === 'harmonyos') return 'harmonyos'; + if (isIosFamily(device)) return 'ios'; + return 'android'; +} + +function keyboardActionInput(context: DaemonCommandContext): KeyboardActionInput { + return { + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place `keyboard` reaches a device (ADR 0019 §9). Exactly one action-selected use is + * admitted and bound — `status`, `dismiss`, or `enter`, never all three — mirroring R35's find + * closure: resolve one use per action, bind once. + */ +export async function resolveBoundKeyboardRuntime( + params: { + device: DeviceInfo; + } & RuntimeAdmissionBindings & { positionals: readonly string[] }, +): Promise { + const action = readKeyboardAction(params.positionals); + const platform = keyboardPlatformLabel(params.device); + if (action === 'status') { + const admission = await admitRuntimeUse({ + command: 'keyboard status', + device: params.device, + use: keyboardStatusUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { + ok: true, + execute: async (context) => await executeKeyboardStatus(runtime, platform, context), + }; + } + if (action === 'dismiss') { + const admission = await admitRuntimeUse({ + command: 'keyboard dismiss', + device: params.device, + use: keyboardDismissUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { + ok: true, + execute: async (context) => await executeKeyboardDismiss(runtime, platform, context), + }; + } + const admission = await admitRuntimeUse({ + command: 'keyboard enter', + device: params.device, + use: keyboardEnterUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { + ok: true, + execute: async (context) => await executeKeyboardEnter(runtime, platform, context), + }; +} + +/** The ONE place a bound `keyboardStatus` executes (R46). Android-only; every other owner refuses. */ +async function executeKeyboardStatus( + runtime: Readonly<{ + operations: Readonly<{ + keyboardStatus: (input: KeyboardActionInput) => Promise; + }>; + }>, + platform: 'android' | 'harmonyos' | 'ios', + context: DaemonCommandContext, +): Promise> { + const state = await runtime.operations.keyboardStatus(keyboardActionInput(context)); + return { + platform, + action: 'status', + visible: state.visible, + inputType: state.inputType, + type: state.type, + inputMethodPackage: state.inputMethodPackage, + focusedPackage: state.focusedPackage, + focusedResourceId: state.focusedResourceId, + inputOwner: state.inputOwner, + }; +} + +/** The ONE place a bound `keyboardDismiss` executes (R46). */ +async function executeKeyboardDismiss( + runtime: Readonly<{ + operations: Readonly<{ + keyboardDismiss: (input: KeyboardActionInput) => Promise; + }>; + }>, + platform: 'android' | 'harmonyos' | 'ios', + context: DaemonCommandContext, +): Promise> { + const result = await runtime.operations.keyboardDismiss(keyboardActionInput(context)); + if (platform === 'ios') { + return { + platform, + action: 'dismiss', + wasVisible: result.wasVisible, + dismissed: result.dismissed, + visible: result.visible, + mechanism: result.mechanism, + ...successText(iosKeyboardDismissMessage(result.dismissed === true, result.mechanism)), + }; + } + if (platform === 'harmonyos') { + return { platform, action: 'dismiss', ...successText('Keyboard dismissed') }; + } + return { + platform, + action: 'dismiss', + attempts: result.attempts, + wasVisible: result.wasVisible, + dismissed: result.dismissed, + visible: result.visible, + inputType: result.inputType, + type: result.type, + inputMethodPackage: result.inputMethodPackage, + focusedPackage: result.focusedPackage, + focusedResourceId: result.focusedResourceId, + inputOwner: result.inputOwner, + }; +} + +/** The ONE place a bound `keyboardEnter` executes (R46). */ +async function executeKeyboardEnter( + runtime: Readonly<{ + operations: Readonly<{ + keyboardEnter: (input: KeyboardActionInput) => Promise; + }>; + }>, + platform: 'android' | 'harmonyos' | 'ios', + context: DaemonCommandContext, +): Promise> { + const result = await runtime.operations.keyboardEnter(keyboardActionInput(context)); + if (platform === 'ios') { + return { + platform, + action: 'enter', + visible: result.visible, + wasVisible: result.wasVisible, + ...successText('Keyboard enter pressed'), + }; + } + return { platform, action: 'enter', ...successText('Keyboard enter pressed') }; +} + +// Discloses which mechanism actually resigned the keyboard (#1598): a bare "dismissed" would +// leave the caller unable to tell a vouched-for control tap from app-side coincidence. +function iosKeyboardDismissMessage(dismissed: boolean, mechanism: string | undefined): string { + if (!dismissed) return 'Keyboard already hidden'; + if (mechanism === 'dismissKey') { + return 'Keyboard dismissed via its dismiss key'; + } + return 'Keyboard dismissed'; +} diff --git a/src/daemon/orientation-runtime.ts b/src/daemon/orientation-runtime.ts new file mode 100644 index 0000000000..7570a0a193 --- /dev/null +++ b/src/daemon/orientation-runtime.ts @@ -0,0 +1,70 @@ +import { + orientationRuntimeUse, + type SetOrientationInput, + type SetOrientationResult, +} from '@agent-device/contracts/platform'; +import { parseDeviceRotation, type DeviceRotation } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; + +/** `orientation `, on the same parse the retired leaf used. */ +export function readRequestedOrientation(positionals: readonly string[]): DeviceRotation { + return parseDeviceRotation(positionals[0]); +} + +/** The neutral intent one orientation change carries, projected from a resolved command context. */ +function setOrientationInput( + rotation: DeviceRotation, + context: DaemonCommandContext, +): SetOrientationInput { + return { + rotation, + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place `orientation` reaches a device (ADR 0019). Admission inspects the exact owner's + * `setOrientation` fact and binds once, before the dispatcher runs, so an owner that cannot rotate + * is refused rather than discovered mid-execution. + */ +export async function resolveBoundOrientationRuntime( + params: { + device: DeviceInfo; + positionals: readonly string[]; + } & RuntimeAdmissionBindings, +): Promise { + const rotation = readRequestedOrientation(params.positionals); + return await resolveBoundGenericRuntime( + { + command: 'orientation', + device: params.device, + use: orientationRuntimeUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }, + (runtime, context) => executeSetOrientation(runtime, rotation, context), + ); +} + +/** The ONE place a bound `setOrientation` executes (R44). */ +async function executeSetOrientation( + runtime: Readonly<{ + operations: Readonly<{ + setOrientation: (input: SetOrientationInput) => Promise; + }>; + }>, + requestedRotation: DeviceRotation, + context: DaemonCommandContext, +): Promise> { + const result = await runtime.operations.setOrientation( + setOrientationInput(requestedRotation, context), + ); + const orientation = result?.orientation ?? requestedRotation; + return { action: 'orientation', orientation, ...successText(`Rotated to ${orientation}`) }; +} diff --git a/src/daemon/runtime-admission.ts b/src/daemon/runtime-admission.ts index 378821d107..74f9fd382d 100644 --- a/src/daemon/runtime-admission.ts +++ b/src/daemon/runtime-admission.ts @@ -9,6 +9,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import { errorResponse, type DaemonFailureResponse } from './handlers/response.ts'; +import type { DaemonCommandContext } from './context.ts'; +import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; /** Builds the failure a command reports when its exact device cell does not admit an operation. */ export type UnavailableRuntimeResponse = ( @@ -89,6 +91,41 @@ export async function admitRuntimeUse< return { type: 'runtime', runtime: await admitted.bind(request.device, request.use) }; } +/** + * The generic-route admit-then-bind shape every single-use leaf shares (focus, back, home, + * orientation, tv-remote): admit the exact owner's fact, narrow the binding, and defer execution + * to a closure the dispatcher invokes with its resolved context. One shared shape here is what + * keeps five near-identical leaves from drifting into five copies of the same wiring. + */ +export async function resolveBoundGenericRuntime< + const Required extends readonly RuntimeOperationKey[], + const Preferred extends readonly Exclude< + RuntimeOperationKey, + Required[number] + >[], + const Conditional extends readonly Exclude< + RuntimeOperationKey, + Required[number] | Preferred[number] + >[], +>( + request: Omit & + Readonly<{ use: RuntimeUse }>, + execute: ( + runtime: BoundDeviceRuntime< + RuntimeUse + >, + dispatchContext: DaemonCommandContext, + ) => Promise | void>, +): Promise { + const admission = await admitRuntimeUse(request); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { + ok: true, + execute: async ({ dispatchContext }) => await execute(runtime, dispatchContext), + }; +} + function requireFactsInspection( inspectFacts: InspectDeviceRuntimeFacts | undefined, ): InspectDeviceRuntimeFacts { diff --git a/src/daemon/tv-remote-runtime.ts b/src/daemon/tv-remote-runtime.ts new file mode 100644 index 0000000000..7435691d54 --- /dev/null +++ b/src/daemon/tv-remote-runtime.ts @@ -0,0 +1,79 @@ +import { tvRemoteRuntimeUse, type TvRemoteInput } from '@agent-device/contracts/platform'; +import { parseTvRemoteButton, type TvRemoteButton } from '@agent-device/contracts/interaction'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { successText } from '../utils/success-text.ts'; +import { requireIntInRange } from '../utils/validation.ts'; +import type { DaemonCommandContext } from './context.ts'; +import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; + +/** `tv-remote