diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 5de41a793..4e7bd58c5 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -76,6 +76,10 @@ "types": "./src/back-mode.ts", "default": "./src/back-mode.ts" }, + "./back-runtime": { + "types": "./src/back-runtime.ts", + "default": "./src/back-runtime.ts" + }, "./capture": { "types": "./src/facades/capture.ts", "default": "./src/facades/capture.ts" @@ -144,6 +148,10 @@ "types": "./src/gesture-plan-types.ts", "default": "./src/gesture-plan-types.ts" }, + "./home-runtime": { + "types": "./src/home-runtime.ts", + "default": "./src/home-runtime.ts" + }, "./interaction": { "types": "./src/facades/interaction.ts", "default": "./src/facades/interaction.ts" @@ -156,10 +164,18 @@ "types": "./src/interaction-guarantees.ts", "default": "./src/interaction-guarantees.ts" }, + "./interactor-operation-catalog": { + "types": "./src/interactor-operation-catalog.ts", + "default": "./src/interactor-operation-catalog.ts" + }, "./interactor-types": { "types": "./src/interactor-types.ts", "default": "./src/interactor-types.ts" }, + "./keyboard-runtime": { + "types": "./src/keyboard-runtime.ts", + "default": "./src/keyboard-runtime.ts" + }, "./logs-runtime-plan": { "types": "./src/logs-runtime-plan.ts", "default": "./src/logs-runtime-plan.ts" @@ -180,6 +196,10 @@ "types": "./src/facades/observability.ts", "default": "./src/facades/observability.ts" }, + "./orientation-runtime": { + "types": "./src/orientation-runtime.ts", + "default": "./src/orientation-runtime.ts" + }, "./platform": { "types": "./src/facades/platform.ts", "default": "./src/facades/platform.ts" @@ -272,6 +292,10 @@ "types": "./src/tv-remote.ts", "default": "./src/tv-remote.ts" }, + "./tv-remote-runtime": { + "types": "./src/tv-remote-runtime.ts", + "default": "./src/tv-remote-runtime.ts" + }, "./type-text-runtime": { "types": "./src/type-text-runtime.ts", "default": "./src/type-text-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 000000000..2a33277ec --- /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 000000000..10ea2e65d --- /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 777c56cac..e7400fc14 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 c3893da25..72c4e0372 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 000000000..ac95afce8 --- /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 000000000..e34d9bde2 --- /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-operation-catalog.test.ts b/packages/contracts/src/interactor-operation-catalog.test.ts new file mode 100644 index 000000000..63f7e46ea --- /dev/null +++ b/packages/contracts/src/interactor-operation-catalog.test.ts @@ -0,0 +1,174 @@ +import { expect, test, vi } from 'vitest'; +import { + bindAdmittedLocalInteractorOperations, + bindAdmittedProviderInteractorOperations, +} from './interactor-operation-catalog.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +const available = { available: true } as const; +const unavailable = { available: false, reason: 'unsupported-device-kind' } as const; + +test('binds every operation the facts admitted, driving the resolved interactor', async () => { + const back = vi.fn(async () => undefined); + const home = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ back, home }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindAdmittedLocalInteractorOperations({ + device, + signal, + resolveInteractor, + facts: { back: available, home: available }, + }); + + expect(operations.back).toBeTypeOf('function'); + expect(operations.home).toBeTypeOf('function'); + await operations.back?.({}); + await operations.home?.({}); + expect(back).toHaveBeenCalledOnce(); + expect(home).toHaveBeenCalledOnce(); +}); + +test('skips an operation the facts refused', () => { + const operations = bindAdmittedLocalInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor: vi.fn(), + facts: { back: available, home: unavailable }, + }); + + expect(operations.back).toBeTypeOf('function'); + expect(operations.home).toBeUndefined(); +}); + +// #1955 review: the facts a caller passes are the ONLY source of truth for what binds — there is +// no separate, caller-maintained operation list that could drift from them. An operation the +// facts admit binds even if the caller only meant to name a couple of others. +test('binds every admitted operation the facts name, not just the ones a caller had in mind', () => { + const operations = bindAdmittedLocalInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor: vi.fn(), + facts: { back: available, home: available, tvRemote: available }, + }); + + expect(operations.back).toBeTypeOf('function'); + expect(operations.home).toBeTypeOf('function'); + expect(operations.tvRemote).toBeTypeOf('function'); +}); + +// #1955 review: the walk list and the per-operation binder records are both derived from one +// canonical tuple now, so they can't drift at the type level — this pins the runtime behavior +// that actually matters: every one of the seven operations binds when its fact admits it, not +// just the two or three most tests happen to exercise. +test('binds every one of the seven navigation operations when every fact admits it', async () => { + const back = vi.fn(async () => undefined); + const home = vi.fn(async () => undefined); + const setOrientation = vi.fn(async () => undefined); + const tvRemote = vi.fn(async () => undefined); + const keyboardStatus = vi.fn(async () => ({ visible: false })); + const keyboardDismiss = vi.fn(async () => ({ kind: 'acknowledged' })); + const keyboardEnter = vi.fn(async () => undefined); + const resolveInteractor = vi.fn( + async () => + ({ + back, + home, + setOrientation, + tvRemote, + keyboardStatus, + keyboardDismiss, + keyboardEnter, + }) as unknown as Interactor, + ); + + const operations = bindAdmittedLocalInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor, + facts: { + back: available, + home: available, + setOrientation: available, + tvRemote: available, + keyboardStatus: available, + keyboardDismiss: available, + keyboardEnter: available, + }, + }); + + expect(operations.back).toBeTypeOf('function'); + expect(operations.home).toBeTypeOf('function'); + expect(operations.setOrientation).toBeTypeOf('function'); + expect(operations.tvRemote).toBeTypeOf('function'); + expect(operations.keyboardStatus).toBeTypeOf('function'); + expect(operations.keyboardDismiss).toBeTypeOf('function'); + expect(operations.keyboardEnter).toBeTypeOf('function'); + + await operations.back?.({}); + await operations.home?.({}); + await operations.setOrientation?.({ rotation: 'landscape-left' }); + await operations.tvRemote?.({ button: 'select' }); + await operations.keyboardStatus?.({}); + await operations.keyboardDismiss?.({}); + await operations.keyboardEnter?.({}); + + expect(back).toHaveBeenCalledOnce(); + expect(home).toHaveBeenCalledOnce(); + expect(setOrientation).toHaveBeenCalledOnce(); + expect(tvRemote).toHaveBeenCalledOnce(); + expect(keyboardStatus).toHaveBeenCalledOnce(); + expect(keyboardDismiss).toHaveBeenCalledOnce(); + expect(keyboardEnter).toHaveBeenCalledOnce(); +}); + +test('treats an operation missing from a partial facts map as unavailable', () => { + const operations = bindAdmittedLocalInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor: vi.fn(), + facts: { back: available }, + }); + + expect(operations.back).toBeTypeOf('function'); + expect(operations.tvRemote).toBeUndefined(); +}); + +test('a provider binding drives its own resolved interactor for every admitted operation', async () => { + const tvRemote = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(() => ({ tvRemote }) as unknown as Interactor); + + const operations = bindAdmittedProviderInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor, + facts: { tvRemote: available }, + }); + + expect(operations.back).toBeUndefined(); + expect(operations.home).toBeUndefined(); + expect(operations.tvRemote).toBeTypeOf('function'); + await operations.tvRemote?.({ button: 'select' }); + expect(tvRemote).toHaveBeenCalledWith('select', undefined); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindAdmittedProviderInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + facts: { keyboardDismiss: available }, + }); + + await expect(operations.keyboardDismiss?.({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + }); +}); diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts new file mode 100644 index 000000000..57c19326e --- /dev/null +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -0,0 +1,165 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { bindLocalBackInteractor, bindProviderBackInteractor } from './back-runtime.ts'; +import { bindLocalHomeInteractor, bindProviderHomeInteractor } from './home-runtime.ts'; +import { + bindLocalKeyboardDismissInteractor, + bindLocalKeyboardEnterInteractor, + bindLocalKeyboardStatusInteractor, + bindProviderKeyboardDismissInteractor, + bindProviderKeyboardEnterInteractor, + bindProviderKeyboardStatusInteractor, +} from './keyboard-runtime.ts'; +import { + bindLocalOrientationInteractor, + bindProviderOrientationInteractor, +} from './orientation-runtime.ts'; +import type { + LocalInteractorOperationResolver, + ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import { + bindLocalTvRemoteInteractor, + bindProviderTvRemoteInteractor, +} from './tv-remote-runtime.ts'; + +/** + * The seven navigation/keyboard operations every owner admits from the same shape: one fact, + * one bind call, no owner mechanics in between. This tuple is the single canonical declaration: + * the {@link NavigationInteractorOperation} union type is derived from it below, and + * `LOCAL_BINDERS`/`PROVIDER_BINDERS`'s `Record` types are then + * checked against that derived union — so a member can never be added to one and silently missing + * from another (#1955 review: an earlier design declared the walk list as a plain + * `readonly NavigationInteractorOperation[]`, independently of the union it walked, which would + * have compiled even missing a member; a caller-maintained `operations: [...]` array duplicating + * this same set was removed for the identical reason in an earlier review round). Not + * caller-supplied: `facts[operation]` is the only thing that decides whether an operation binds — + * a key this tuple names but the caller's facts never define is simply never available + * (`facts[operation]?.available` reads `undefined`), which is how a caller passing a narrower, + * dedicated facts object (e.g. Limrun's keyboard-less navigation facts) opts a subset out. + */ +const NAVIGATION_INTERACTOR_OPERATIONS = [ + 'back', + 'home', + 'setOrientation', + 'tvRemote', + 'keyboardStatus', + 'keyboardDismiss', + 'keyboardEnter', +] as const; + +export type NavigationInteractorOperation = (typeof NAVIGATION_INTERACTOR_OPERATIONS)[number]; + +type LocalBinderParams = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalInteractorOperationResolver; +}>; +type ProviderBinderParams = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderInteractorOperationResolver; +}>; + +const LOCAL_BINDERS: Readonly< + Record< + NavigationInteractorOperation, + (params: LocalBinderParams) => Partial + > +> = Object.freeze({ + back: bindLocalBackInteractor, + home: bindLocalHomeInteractor, + setOrientation: bindLocalOrientationInteractor, + tvRemote: bindLocalTvRemoteInteractor, + keyboardStatus: bindLocalKeyboardStatusInteractor, + keyboardDismiss: bindLocalKeyboardDismissInteractor, + keyboardEnter: bindLocalKeyboardEnterInteractor, +}); + +const PROVIDER_BINDERS: Readonly< + Record< + NavigationInteractorOperation, + (params: ProviderBinderParams) => Partial + > +> = Object.freeze({ + back: bindProviderBackInteractor, + home: bindProviderHomeInteractor, + setOrientation: bindProviderOrientationInteractor, + tvRemote: bindProviderTvRemoteInteractor, + keyboardStatus: bindProviderKeyboardStatusInteractor, + keyboardDismiss: bindProviderKeyboardDismissInteractor, + keyboardEnter: bindProviderKeyboardEnterInteractor, +}); + +/** + * The operation-facts slice every caller already holds: an owner's full `RuntimeFacts.operations` + * (narrows structurally — extra keys are ignored), or, for an owner whose navigation facts are + * assembled standalone (see `platform-apple/src/navigation/runtime.ts`, `provider-limrun`'s + * `limrunNavigationOperationFacts`), that flat map directly — which may cover only the subset of + * operations the owner admits at all, so a missing key here is simply never bindable. + */ +type NavigationOperationFacts = Readonly< + Partial> +>; + +/** Walks every navigation operation against one binder table, binding each the facts admitted. */ +function bindAdmittedInteractorOperations( + binders: Readonly< + Record< + NavigationInteractorOperation, + (params: { + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: Resolver; + }) => Partial + > + >, + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: Resolver; + facts: NavigationOperationFacts; + }>, +): Partial { + const { device, signal, resolveInteractor, facts } = params; + const bound: Partial = {}; + for (const operation of NAVIGATION_INTERACTOR_OPERATIONS) { + if (facts[operation]?.available) { + Object.assign(bound, binders[operation]({ device, signal, resolveInteractor })); + } + } + return bound; +} + +/** + * Binds whichever local operations the owner's own facts admitted. The owner keeps full + * authority — its facts alone decide what binds — this only removes the seven-times-repeated + * ternary that read them. + */ +export function bindAdmittedLocalInteractorOperations( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalInteractorOperationResolver; + facts: NavigationOperationFacts; + }>, +): Partial { + return bindAdmittedInteractorOperations(LOCAL_BINDERS, params); +} + +/** + * Binds whichever provider operations the owner's own facts admitted. Provider bindings fail + * closed when their exact owner no longer exposes its interactor (see the individual + * `bindProvider…Interactor` functions this table dispatches to). + */ +export function bindAdmittedProviderInteractorOperations( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderInteractorOperationResolver; + facts: NavigationOperationFacts; + }>, +): Partial { + return bindAdmittedInteractorOperations(PROVIDER_BINDERS, params); +} diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index c3a7aefa4..eb9dbd7a1 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -170,6 +170,56 @@ 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, discriminated by which owner produced it: Android's IME probe + * fields, or iOS's `mechanism` disclosure (#1598), or HarmonyOS's bare acknowledgment (its HDC + * key press reports nothing beyond success). The daemon derives the wire `platform` label from + * `kind` rather than re-deriving it from the device (#1955 review), so an owner can only ever + * produce its own result shape — an android-probe result under an `ios` label is unrepresentable. + */ +export type KeyboardDismissResult = + | Readonly<{ + kind: 'ime-probe'; + attempts?: number; + wasVisible?: boolean; + dismissed?: boolean; + visible?: boolean; + inputType?: string; + type?: string; + inputMethodPackage?: string; + focusedPackage?: string; + focusedResourceId?: string; + inputOwner?: string; + }> + | Readonly<{ + kind: 'mechanism'; + wasVisible?: boolean; + dismissed?: boolean; + visible?: boolean; + mechanism?: string; + }> + | Readonly<{ kind: 'acknowledged' }>; + +/** 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 +306,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 000000000..c1876a905 --- /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 000000000..f621a6a15 --- /dev/null +++ b/packages/contracts/src/keyboard-runtime.ts @@ -0,0 +1,215 @@ +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 all three actions share: the runner context resolution. + */ +async function resolveKeyboardInteractor( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, + input: KeyboardActionInput, +): Promise { + signal.throwIfAborted(); + return await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); +} + +const KEYBOARD_ACTION_LABELS = { + keyboardStatus: 'keyboard status', + keyboardDismiss: 'keyboard dismiss', + keyboardEnter: 'keyboard enter', +} as const satisfies Record; + +/** + * Binds whichever keyboard action `key` names against one resolved interactor. The three actions + * differ only by which `Interactor` method they call and what it returns — both read off `key` + * itself, so one generic body replaces three copies that differed by nothing else. + */ +function bindKeyboardAction( + key: Key, + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): Pick { + const action = async (input: KeyboardActionInput) => { + const interactor = await resolveKeyboardInteractor(signal, resolveInteractor, input); + const method = requireKeyboardMethod(interactor[key], KEYBOARD_ACTION_LABELS[key]); + return await (method as () => Promise).call(interactor); + }; + return Object.freeze({ [key]: action }) as Pick; +} + +export type LocalKeyboardInteractorResolver = LocalInteractorOperationResolver; +export type ProviderKeyboardInteractorResolver = ProviderInteractorOperationResolver; + +function bindLocalKeyboardAction( + key: Key, + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalKeyboardInteractorResolver; + }>, +): Pick { + return bindKeyboardAction(key, params.signal, localInteractorSource(params)); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +function bindProviderKeyboardAction( + key: Key, + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderKeyboardInteractorResolver; + }>, +): Pick { + return bindKeyboardAction( + key, + params.signal, + providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS[key] }), + ); +} + +export function bindLocalKeyboardStatusInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalKeyboardInteractorResolver; + }>, +): KeyboardStatusRuntimeOperations { + return bindLocalKeyboardAction('keyboardStatus', params); +} + +export function bindProviderKeyboardStatusInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderKeyboardInteractorResolver; + }>, +): KeyboardStatusRuntimeOperations { + return bindProviderKeyboardAction('keyboardStatus', params); +} + +export function bindLocalKeyboardDismissInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalKeyboardInteractorResolver; + }>, +): KeyboardDismissRuntimeOperations { + return bindLocalKeyboardAction('keyboardDismiss', params); +} + +export function bindProviderKeyboardDismissInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderKeyboardInteractorResolver; + }>, +): KeyboardDismissRuntimeOperations { + return bindProviderKeyboardAction('keyboardDismiss', params); +} + +export function bindLocalKeyboardEnterInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalKeyboardInteractorResolver; + }>, +): KeyboardEnterRuntimeOperations { + return bindLocalKeyboardAction('keyboardEnter', params); +} + +export function bindProviderKeyboardEnterInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderKeyboardInteractorResolver; + }>, +): KeyboardEnterRuntimeOperations { + return bindProviderKeyboardAction('keyboardEnter', params); +} diff --git a/packages/contracts/src/orientation-runtime.test.ts b/packages/contracts/src/orientation-runtime.test.ts new file mode 100644 index 000000000..1c1b6445a --- /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 000000000..601c17627 --- /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 2c93a6181..1941cadb8 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 9bf2abdb7..9ed945a90 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 55c94a807..ee7d85cb0 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 000000000..367b29013 --- /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 000000000..8112bdd75 --- /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 6fc3f5c78..7ff00f2fe 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 baf8e3413..2f1150576 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -1,11 +1,11 @@ +import type { EnsureReadyInput } from '@agent-device/contracts/device-readiness-runtime'; +import type { NetworkDumpInput } from '@agent-device/contracts/network-runtime'; +import type { DeviceBinding, RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import type { - DeviceBinding, - NetworkDumpInput, PlatformRuntimeHost, PlatformRuntimeOperations, PlatformRuntimeOwner, - EnsureReadyInput, -} from '@agent-device/contracts/platform'; +} from '@agent-device/contracts/platform-runtime-operations'; import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, @@ -33,6 +33,12 @@ import { typeTextRuntimeOperationFacts, } from '@agent-device/contracts/type-text-runtime'; import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; +import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createAndroidAppLogRuntime } from './logs/runtime.ts'; import { dumpAndroidNetworkTraffic } from './network/runtime.ts'; @@ -159,6 +165,20 @@ 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; +} + export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAndroidAppLogRuntime(host); const inspectFacts = async (device: Parameters[0]) => { @@ -197,6 +217,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, @@ -278,6 +309,12 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor resolveInteractor: host.localInteractors.resolve, }) : {}), + ...bindAdmittedLocalInteractorOperations({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + facts: facts.operations, + }), ensureReady: async (input: EnsureReadyInput) => await ensureAndroidReady( host, diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts new file mode 100644 index 000000000..4825cef42 --- /dev/null +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -0,0 +1,140 @@ +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; +import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { isTvOsDevice, resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; + +const available = Object.freeze({ available: true } as const); + +const backKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'back is supported on Apple simulators and physical devices.', +} as const); +/** watchOS has no XCUITest-driveable UI (ADR-0009): no Apple interactor can be constructed for + * it, so every interactor-backed operation below stays unavailable there regardless of what the + * retired per-command capability table said for it — facts are the support authority (ADR 0019), + * not a mirror of a table that never modeled interactor constructibility. */ +const backOsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); +/** No apple-family closure ever gated `back` beyond device kind: every other Apple OS, tvOS + * included (the interactor drives the remote's Menu button there), supports it. */ +function appleBackFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return backKindUnavailable; + return resolveDeviceAppleOs(device) === 'watchos' ? backOsUnavailable : available; +} + +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 on macOS, which + * drives an already-running app with no springboard home; also unavailable on watchOS, whose + * interactor cannot be constructed at all (see {@link backOsUnavailable}). */ +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; + const os = resolveDeviceAppleOs(device); + return os === 'macos' || os === 'watchos' ? homeLifecycleUnavailable : available; +} + +/** + * The per-AppleOS mobile-input eligibility `orientation` and `keyboard` (dismiss/enter) share: + * unavailable on tvOS (focus-only XCUIRemote navigation, no orientation or keyboard), macOS (an + * AppKit desktop host, no device orientation or software keyboard), and watchOS (no constructible + * interactor at all, see {@link backOsUnavailable}). 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' && os !== 'watchos'; +} + +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; +} + +/** The navigation cells: back, home, orientation, tv-remote, and keyboard status/dismiss/enter. */ +export function appleNavigationFacts(device: DeviceInfo) { + return Object.freeze({ + ...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), + }), + }); +} + +/** Binds whichever navigation operations {@link appleNavigationFacts} admitted. */ +export function createAppleNavigationOperations(params: { + host: Pick; + device: DeviceInfo; + signal: AbortSignal; +}) { + const { host, device, signal } = params; + return bindAdmittedLocalInteractorOperations({ + device, + signal, + resolveInteractor: host.localInteractors.resolve, + facts: appleNavigationFacts(device), + }); +} diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index b366642f0..b77f1cb85 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -153,6 +153,96 @@ 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: () => {} }, + }, + }); + expectNavigationAndKeyboardFacts(binding, device); + }, +); + +/** Both the fact and the bound operation function agree on availability, for one operation. */ +function expectOperationAvailability( + binding: DeviceBinding, + operation: keyof PlatformRuntimeOperations, + available: boolean, +): void { + expect(binding.facts.operations[operation].available).toBe(available); + expect(binding.operations[operation]).toBeTypeOf(available ? 'function' : 'undefined'); +} + +/** + * watchOS has no constructible Apple interactor (XCUITest cannot drive its UI, ADR-0009), so every + * interactor-backed operation here stays unavailable there regardless of what else gates it. + */ +function expectNavigationAndKeyboardFacts( + binding: DeviceBinding, + device: DeviceInfo, +): void { + // Every simulator/device leaf supports back except watchOS, tvOS's Menu remote press + // included — no apple-family closure ever gated it beyond device kind and interactor + // constructibility. + expectOperationAvailability(binding, 'back', device.appleOs !== 'watchos'); + + // home is unavailable on macOS, which drives an already-running app with no springboard, and + // on watchOS. + expectOperationAvailability( + binding, + 'home', + device.appleOs !== 'macos' && device.appleOs !== 'watchos', + ); + + // orientation and keyboard dismiss/enter share mobile-input eligibility: unavailable on tvOS + // (focus-only XCUIRemote navigation), macOS (an AppKit desktop host), and watchOS. + const mobileInputEligible = + device.appleOs !== 'tvos' && device.appleOs !== 'macos' && device.appleOs !== 'watchos'; + expectOperationAvailability(binding, 'setOrientation', mobileInputEligible); + expectOperationAvailability(binding, 'keyboardDismiss', mobileInputEligible); + expectOperationAvailability(binding, 'keyboardEnter', mobileInputEligible); + + expectKeyboardStatusFact(binding, mobileInputEligible); + expectTvRemoteFact(binding, device); +} + +/** 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. */ +function expectKeyboardStatusFact( + binding: DeviceBinding, + mobileInputEligible: boolean, +): void { + expect(binding.facts.operations.keyboardStatus.available).toBe(false); + if (mobileInputEligible) { + expect(binding.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. */ +function expectTvRemoteFact( + binding: DeviceBinding, + device: DeviceInfo, +): void { + const tvRemoteAvailable = device.appleOs === 'tvos'; + expectOperationAvailability(binding, 'tvRemote', tvRemoteAvailable); + if (!tvRemoteAvailable) { + expect(binding.facts.operations.tvRemote).toHaveProperty( + 'hint', + 'tv-remote is supported only on tvOS devices.', + ); + } +} + 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 91512a4f1..eee098e3d 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -48,6 +48,7 @@ import { appleAppDeploymentFacts, createAppleAppDeploymentOperations, } from './deployment/runtime.ts'; +import { appleNavigationFacts, createAppleNavigationOperations } from './navigation/runtime.ts'; import { bindAppleFindSelectorRuntime, bindAppleFindTextRuntime, @@ -281,6 +282,7 @@ 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) }), + ...appleNavigationFacts(device), ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, @@ -298,102 +300,108 @@ 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 operations: DeviceBinding['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, + }), + ), + ...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, }), - 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({ - 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, - }), - ), - ...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), - })), + ), + ...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, + }), + ), + ...createAppleNavigationOperations({ + host, + device: request.device, + signal: request.scope.signal, }), + ...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), + })), + }; + 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 10d43198a..5cdfd05bb 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 1d817d93a..4f2d65db6 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -1,19 +1,23 @@ +import type { DeviceBinding, RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import type { - DeviceBinding, PlatformRuntimeHost, PlatformRuntimeOperations, PlatformRuntimeOwner, - RuntimeOperationFact, -} from '@agent-device/contracts/platform'; +} from '@agent-device/contracts/platform-runtime-operations'; import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; import { bindLocalFocusInteractor, focusRuntimeOperationFacts, } from '@agent-device/contracts/focus-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; +import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { bindLocalScreenshotInteractor, @@ -24,6 +28,7 @@ import { bindLocalSnapshotInteractor, snapshotRuntimeOperationFacts, } from '@agent-device/contracts/snapshot-runtime'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import { bindLocalTypeTextInteractor, typeTextRuntimeOperationFacts, @@ -142,6 +147,24 @@ 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); + export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createHarmonyAppLogRuntime(host); const inspectFacts = async (device: Parameters[0]) => { @@ -187,6 +210,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, @@ -262,6 +294,12 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor resolveInteractor: host.localInteractors.resolve, }) : {}), + ...bindAdmittedLocalInteractorOperations({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + facts: facts.operations, + }), 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 96938457c..a0741c2b7 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 9730cd338..95f2b6be7 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -1,16 +1,19 @@ import type { DeviceBinding, - CaptureSnapshotInput, + RuntimeFacts, + RuntimeOperationFact, + RuntimeOperationUnavailability, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeHost, PlatformRuntimeOperations, PlatformRuntimeOwner, - RuntimeFacts, - RuntimeOperationUnavailability, -} from '@agent-device/contracts/platform'; +} from '@agent-device/contracts/platform-runtime-operations'; import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { bindElementTextRuntime, elementTextRuntimeOperationFacts, @@ -19,6 +22,8 @@ import { bindLocalFocusInteractor, focusRuntimeOperationFacts, } from '@agent-device/contracts/focus-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; import { @@ -28,6 +33,7 @@ import { import { captureSnapshotSignal, snapshotRuntimeOperationFacts, + type CaptureSnapshotInput, } from '@agent-device/contracts/snapshot-runtime'; import { bindLocalTypeTextInteractor, @@ -81,6 +87,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, @@ -111,34 +128,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; @@ -147,6 +137,31 @@ 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) : {}), + ...bindAdmittedLocalInteractorOperations({ + ...resolver, + facts: facts.operations, + }), + }; +} + function linuxFacts(device: DeviceInfo): RuntimeFacts { const openTarget = device.kind === 'device' ? supported : openTargetKindUnavailable; const closeTarget = device.kind === 'device' ? supported : closeTargetKindUnavailable; @@ -159,6 +174,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, @@ -177,31 +199,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 a58f27f18..c1c9060f5 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 7669d39ef..2bebde3aa 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -1,17 +1,23 @@ import type { DeviceBinding, + RuntimeFacts, + RuntimeOperationUnavailability, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeHost, PlatformRuntimeOperations, PlatformRuntimeOwner, - RuntimeFacts, - RuntimeOperationUnavailability, -} from '@agent-device/contracts/platform'; +} from '@agent-device/contracts/platform-runtime-operations'; import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { bindVegaApplicationLifecycle } from './lifecycle.ts'; @@ -64,9 +70,15 @@ 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), + ...bindAdmittedLocalInteractorOperations({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + facts: facts.operations, + }), + }), [Symbol.asyncDispose]: async () => undefined, }) satisfies DeviceBinding; }, @@ -87,12 +99,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, @@ -102,6 +137,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, @@ -115,6 +157,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 4d1324138..483317860 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 9631816b4..4fd787f82 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -29,6 +29,11 @@ import { typeTextRuntimeOperationFacts, } from '@agent-device/contracts/type-text-runtime'; import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { bindWebScreenRecordingRuntime } from './recording/runtime.ts'; @@ -68,6 +73,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', @@ -291,6 +303,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 b7224d82d..894487f47 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -1,8 +1,9 @@ -import { narrowDeviceBinding } from '@agent-device/contracts/platform-runtime'; +import { narrowDeviceBinding, type DeviceBinding } from '@agent-device/contracts/platform-runtime'; import { appStateUse, appsRuntimeUse, bootTargetUse, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test, vi } from 'vitest'; @@ -296,6 +297,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 +366,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 f6e45ad07..5cb2f27f1 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -1,19 +1,16 @@ -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppsFilter, ProviderPortReverseOptions } from '@agent-device/contracts/device'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; -import { - bindLimrunInteractionOperations, - limrunInteractionOperationFacts, -} from './interaction-operations.ts'; +import { bindLimrunInteractionOperations } from './interaction-operations.ts'; +import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { AppError } from '@agent-device/kernel/errors'; -import { parseLimrunDeviceId } from './device.ts'; +import { isSupportedLimrunAppLogDevice, parseLimrunDeviceId } from './device.ts'; import type { AppStateRuntimeResult, DeviceBinding, PlatformRuntimeHost, PlatformRuntimeOperations, PlatformRuntimeOwner, - RuntimeFacts, } from '@agent-device/contracts/platform'; import { appLogSessionArtifactsMatch, @@ -22,17 +19,9 @@ import { createAppLogStartResult, readRecentNetworkTrafficFromText, } from '@agent-device/capture-kit'; -import { - applicationLifecycleOperationFacts, - availableApplicationLifecycleOperations, -} from '@agent-device/contracts/application-lifecycle-runtime'; -import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import { availableApplicationLifecycleOperations } from '@agent-device/contracts/application-lifecycle-runtime'; import { providerRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; -import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; -import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; -import { snapshotRuntimeOperationFacts } from '@agent-device/contracts/snapshot-runtime'; -import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import { createLimrunAppLogEnvelope, limrunAppLogDescriptorCodec, @@ -42,10 +31,16 @@ import { startLimrunAppLogPoller, type LimrunAppLogReader } from './app-log-poll import { bindLimrunApplicationLifecycle } from './lifecycle.ts'; import { createLimrunAppDeploymentOperations, - limrunAppDeploymentFacts, type LimrunAppDeploymentRuntimeOptions, } from './deployment-runtime.ts'; import { createLimrunRequestOperationDrain } from './request-cancellation.ts'; +import { + deploymentOptions, + limrunAppLogFacts, + limrunAppLogRecoveryFacts, + limrunLifecycleFacts, + liveSessionUnavailable, +} from './facts-runtime.ts'; export type LimrunAppLogReconnectOutcome = | Readonly<{ status: 'opened'; reader: LimrunAppLogReader }> @@ -78,111 +73,6 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< ): Promise | undefined>; }>; -function deploymentOptions( - options: LimrunPlatformRuntimeOwnerOptions, -): LimrunAppDeploymentRuntimeOptions { - return { ...options, isSessionActive: options.hasLiveSession }; -} - -const available = Object.freeze({ available: true } as const); -const customSnapshotUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Custom snapshot actions are available only for Limrun iOS simulator sessions.', -} as const); -const viewportUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun does not expose viewport resizing.', -} as const); -/** - * A point read needs a local tool (adb uiautomator, the XCUITest runner). Limrun's transport - * carries none of them, so the owner reports no live read and `get` answers from the captured - * tree; provider ownership never borrows the local family read. - */ -const elementTextUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun-owned devices read element text from the captured tree only.', -} as const); -const recordingUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun does not expose an exact-owner screen-recording runtime.', -} as const); -const headlessUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Headless boot is unavailable for provider-owned devices.', -} as const); -const liveSessionUnavailable = Object.freeze({ - available: false, - reason: 'owner-capability-missing', - hint: 'Limrun requires a matching live provider session for this device.', -} as const); -const prepareUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Apple runner preparation is unavailable for Limrun-owned devices.', -} as const); -const openTargetUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun open requires a Limrun-owned iOS simulator or Android emulator.', -} as const); -const closeTargetUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun close requires a Limrun-owned iOS simulator or Android emulator.', -} as const); -const runtimeHintsUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Runtime hints are not applied to provider-owned devices.', -} as const); -const portReverseUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun port reverse requires an active Android Limrun session.', -} as const); -function limrunLifecycleFacts(device: DeviceInfo, live: boolean) { - const openTarget = limrunOpenTargetFact(device, live); - const closeTarget = limrunCloseTargetFact(device, live); - const portReverse = limrunPortReverseFact(device, live); - return applicationLifecycleOperationFacts({ - resolveOpenTarget: openTarget, - prepareApplicationOpen: openTarget, - openApplication: openTarget, - applyRuntimeHints: runtimeHintsUnavailable, - clearRuntimeHints: runtimeHintsUnavailable, - closeApplication: closeTarget, - finalizeApplicationClose: closeTarget, - prepareAppleRunner: prepareUnavailable, - configureProviderPortReverse: portReverse, - }); -} - -function limrunOpenTargetFact(device: DeviceInfo, live: boolean) { - return isSupportedLimrunAppLogDevice(device) - ? live - ? available - : liveSessionUnavailable - : openTargetUnavailable; -} - -function limrunCloseTargetFact(device: DeviceInfo, live: boolean) { - return isSupportedLimrunAppLogDevice(device) - ? live - ? available - : liveSessionUnavailable - : closeTargetUnavailable; -} - -function limrunPortReverseFact(device: DeviceInfo, live: boolean) { - if (!live) return liveSessionUnavailable; - return device.platform === 'android' ? available : portReverseUnavailable; -} - export function createLimrunPlatformRuntimeOwner( options: LimrunPlatformRuntimeOwnerOptions, ): PlatformRuntimeOwner { @@ -196,7 +86,7 @@ export function createLimrunPlatformRuntimeOwner( ownsDevice, inspectFacts: async (device) => hasLiveSession(device) - ? facts(options, device) + ? limrunAppLogFacts(options, device) : createUnavailablePlatformRuntimeFacts(device, owner, { appLog: liveSessionUnavailable, appState: liveSessionUnavailable, @@ -207,6 +97,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), @@ -248,7 +145,9 @@ function bindLimrunAppLogs( signal: AbortSignal, recoveryOnly: boolean, ): DeviceBinding { - const runtimeFacts = recoveryOnly ? recoveryFacts(options, device) : facts(options, device); + const runtimeFacts = recoveryOnly + ? limrunAppLogRecoveryFacts(options, device) + : limrunAppLogFacts(options, device); const deploymentOperationDrain = createLimrunRequestOperationDrain(); const recovery = createAppLogRecoveryOperations({ codec: limrunAppLogDescriptorCodec, @@ -368,6 +267,12 @@ function bindLimrunAppLogs( runtimeFacts.operations, ), ...bindLimrunInteractionOperations({ device, signal, getInteractor: options.getInteractor }), + ...bindAdmittedProviderInteractorOperations({ + device, + signal, + resolveInteractor: (runner) => options.getInteractor(device, runner), + facts: runtimeFacts.operations, + }), ...createLimrunAppDeploymentOperations( deploymentOptions(options), device, @@ -404,120 +309,6 @@ async function currentSessionAvailable( return true; } -function facts( - options: LimrunPlatformRuntimeOwnerOptions, - device: DeviceInfo, -): RuntimeFacts { - const deployment = limrunAppDeploymentFacts(deploymentOptions(options), device); - return Object.freeze({ - device: { - family: device.platform, - ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), - kind: device.kind, - ...(device.target === undefined ? {} : { target: device.target }), - ...(device.iosPhysicalDeviceBackend === undefined - ? {} - : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), - providerMode: 'provider-runtime', - }, - operations: { - appLogInspect: available, - appLogDoctor: available, - appLogStart: available, - 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.', - }, - networkDump: available, - screenRecordingStart: recordingUnavailable, - screenRecordingReattach: recordingUnavailable, - screenRecordingCleanup: recordingUnavailable, - ...snapshotRuntimeOperationFacts({ - capture: available, - customActions: - isIosFamily(device) && device.kind === 'simulator' - ? available - : customSnapshotUnavailable, - withoutActiveApp: available, - }), - ...screenshotRuntimeOperationFacts({ capture: available }), - // Provider ownership is authoritative: no native text reading is exposed, so text waits - // on a Limrun-owned device poll the canonical tree rather than borrowing Apple's. - ...selectorObservationRuntimeOperationFacts({ - findText: customSnapshotUnavailable, - findSelector: customSnapshotUnavailable, - }), - ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), - // Focus rides the same provider interactor the captures do, and a live-session Limrun - // device always has one, so it is available wherever a capture is. - ...limrunInteractionOperationFacts(), - ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), - ensureReady: available, - bootTarget: available, - bootTargetHeadless: headlessUnavailable, - listApps: available, - shutdownTarget: { - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun owns the target lifecycle for provider-owned devices.', - }, - ...limrunLifecycleFacts(device, true), - }, - }); -} - -function recoveryFacts( - options: LimrunPlatformRuntimeOwnerOptions, - device: DeviceInfo, -): RuntimeFacts { - const normalFacts = facts(options, device); - return Object.freeze({ - device: normalFacts.device, - operations: { - ...normalFacts.operations, - appLogInspect: liveSessionUnavailable, - appLogDoctor: liveSessionUnavailable, - appLogStart: liveSessionUnavailable, - appLogReattach: available, - appLogCleanup: available, - appState: liveSessionUnavailable, - networkDump: liveSessionUnavailable, - screenRecordingStart: liveSessionUnavailable, - screenRecordingReattach: liveSessionUnavailable, - screenRecordingCleanup: liveSessionUnavailable, - ...snapshotRuntimeOperationFacts({ - capture: liveSessionUnavailable, - customActions: liveSessionUnavailable, - withoutActiveApp: liveSessionUnavailable, - }), - ...screenshotRuntimeOperationFacts({ capture: liveSessionUnavailable }), - ...selectorObservationRuntimeOperationFacts({ - findText: liveSessionUnavailable, - findSelector: liveSessionUnavailable, - }), - ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), - ...limrunInteractionOperationFacts(liveSessionUnavailable), - ensureReady: liveSessionUnavailable, - bootTarget: liveSessionUnavailable, - bootTargetHeadless: liveSessionUnavailable, - listApps: liveSessionUnavailable, - deployApp: liveSessionUnavailable, - materializeAppSource: liveSessionUnavailable, - deployMaterializedApp: liveSessionUnavailable, - sendPushNotification: liveSessionUnavailable, - shutdownTarget: liveSessionUnavailable, - ...limrunLifecycleFacts(device, false), - }, - }); -} - function backendForDevice(device: DeviceInfo): 'ios-simulator' | 'android' { return device.platform === 'apple' ? 'ios-simulator' : 'android'; } @@ -534,29 +325,3 @@ function descriptorMatchesDevice(descriptor: LimrunAppLogDescriptor, device: Dev : descriptor.platform === 'android') ); } - -function isSupportedLimrunAppLogDevice(device: DeviceInfo): boolean { - const parsed = parseLimrunDeviceId(device.id); - if (!parsed || device.target !== 'mobile') return false; - return parsed.platform === 'ios' - ? isSupportedLimrunIosDevice(device) - : isSupportedLimrunAndroidDevice(device); -} - -function isSupportedLimrunIosDevice(device: DeviceInfo): boolean { - return ( - device.platform === 'apple' && - device.appleOs === 'ios' && - device.kind === 'simulator' && - device.iosPhysicalDeviceBackend === undefined - ); -} - -function isSupportedLimrunAndroidDevice(device: DeviceInfo): boolean { - return ( - device.platform === 'android' && - device.appleOs === undefined && - device.kind === 'emulator' && - device.iosPhysicalDeviceBackend === undefined - ); -} diff --git a/packages/provider-limrun/src/device.ts b/packages/provider-limrun/src/device.ts index dc6a5a9fe..a61e41b3e 100644 --- a/packages/provider-limrun/src/device.ts +++ b/packages/provider-limrun/src/device.ts @@ -42,3 +42,35 @@ export function parseLimrunDeviceId( function limrunDeviceId(platform: LimrunPlatform, leaseId: string): string { return `${LIMRUN_DEVICE_ID_PREFIX}:${platform}:${leaseId}`; } + +/** + * Whether `device` is one Limrun app logs recognize: an id this module itself would have built, + * on the device shape that id's platform implies. Shared by both the owner module + * (`app-log-runtime.ts`) and its facts module (`facts-runtime.ts`) so device-identity support + * has exactly one definition regardless of which one asks. + */ +export function isSupportedLimrunAppLogDevice(device: DeviceInfo): boolean { + const parsed = parseLimrunDeviceId(device.id); + if (!parsed || device.target !== 'mobile') return false; + return parsed.platform === 'ios' + ? isSupportedLimrunIosDevice(device) + : isSupportedLimrunAndroidDevice(device); +} + +function isSupportedLimrunIosDevice(device: DeviceInfo): boolean { + return ( + device.platform === 'apple' && + device.appleOs === 'ios' && + device.kind === 'simulator' && + device.iosPhysicalDeviceBackend === undefined + ); +} + +function isSupportedLimrunAndroidDevice(device: DeviceInfo): boolean { + return ( + device.platform === 'android' && + device.appleOs === undefined && + device.kind === 'emulator' && + device.iosPhysicalDeviceBackend === undefined + ); +} diff --git a/packages/provider-limrun/src/facts-runtime.test.ts b/packages/provider-limrun/src/facts-runtime.test.ts new file mode 100644 index 000000000..4acbc84c3 --- /dev/null +++ b/packages/provider-limrun/src/facts-runtime.test.ts @@ -0,0 +1,76 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test } from 'vitest'; +import { limrunIosSimulator as device, limrunOwnerOptions } from './app-log-runtime.fixtures.ts'; +import { + deploymentOptions, + limrunAppLogFacts, + limrunAppLogRecoveryFacts, + limrunLifecycleFacts, + liveSessionUnavailable, +} from './facts-runtime.ts'; + +test('limrunAppLogFacts admits the live-session app-log cells', () => { + const facts = limrunAppLogFacts(limrunOwnerOptions(), device); + expect(facts.operations.appLogInspect).toEqual({ available: true }); + expect(facts.operations.appLogStart).toEqual({ available: true }); + expect(facts.operations.networkDump).toEqual({ available: true }); +}); + +test('limrunAppLogRecoveryFacts closes the live-only cells but keeps reattach/cleanup available', () => { + const facts = limrunAppLogRecoveryFacts(limrunOwnerOptions(), device); + expect(facts.operations.appLogInspect).toEqual(liveSessionUnavailable); + expect(facts.operations.appLogStart).toEqual(liveSessionUnavailable); + expect(facts.operations.networkDump).toEqual(liveSessionUnavailable); + expect(facts.operations.appLogReattach).toEqual({ available: true }); + expect(facts.operations.appLogCleanup).toEqual({ available: true }); +}); + +test('deploymentOptions forwards hasLiveSession as isSessionActive', () => { + const options = limrunOwnerOptions({ hasLiveSession: () => false }); + expect(deploymentOptions(options).isSessionActive?.(device)).toBe(false); +}); + +test('limrunLifecycleFacts refuses open/close for a device Limrun app logs do not recognize', () => { + const unsupportedDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'some-other-provider:ios:lease-a', + name: 'Not a Limrun device', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + const facts = limrunLifecycleFacts(unsupportedDevice, true); + expect(facts.resolveOpenTarget).toMatchObject({ + available: false, + hint: 'Limrun open requires a Limrun-owned iOS simulator or Android emulator.', + }); + expect(facts.closeApplication).toMatchObject({ + available: false, + hint: 'Limrun close requires a Limrun-owned iOS simulator or Android emulator.', + }); +}); + +test('limrunLifecycleFacts requires a live session to open/close a recognized device', () => { + const facts = limrunLifecycleFacts(device, false); + expect(facts.resolveOpenTarget).toEqual(liveSessionUnavailable); + expect(facts.closeApplication).toEqual(liveSessionUnavailable); +}); + +test('limrunLifecycleFacts gates port reverse to a live Android session', () => { + const androidDevice: DeviceInfo = { + platform: 'android', + id: 'limrun:android:lease-a', + name: 'Limrun Android', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + expect(limrunLifecycleFacts(androidDevice, true).configureProviderPortReverse).toEqual({ + available: true, + }); + expect(limrunLifecycleFacts(device, true).configureProviderPortReverse).toMatchObject({ + available: false, + hint: 'Limrun port reverse requires an active Android Limrun session.', + }); +}); diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts new file mode 100644 index 000000000..910bf791a --- /dev/null +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -0,0 +1,247 @@ +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; +import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import type { PlatformRuntimeOperations, RuntimeFacts } from '@agent-device/contracts/platform'; +import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; +import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; +import { snapshotRuntimeOperationFacts } from '@agent-device/contracts/snapshot-runtime'; +import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; +import type { LimrunPlatformRuntimeOwnerOptions } from './app-log-runtime.ts'; +import { isSupportedLimrunAppLogDevice } from './device.ts'; +import { + limrunAppDeploymentFacts, + type LimrunAppDeploymentRuntimeOptions, +} from './deployment-runtime.ts'; +import { + limrunInteractionOperationFacts, + limrunKeyboardOperationFacts, + limrunNavigationOperationFacts, +} from './interaction-operations.ts'; + +const available = Object.freeze({ available: true } as const); +const customSnapshotUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Custom snapshot actions are available only for Limrun iOS simulator sessions.', +} as const); +const viewportUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun does not expose viewport resizing.', +} as const); +/** + * A point read needs a local tool (adb uiautomator, the XCUITest runner). Limrun's transport + * carries none of them, so the owner reports no live read and `get` answers from the captured + * tree; provider ownership never borrows the local family read. + */ +const elementTextUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun-owned devices read element text from the captured tree only.', +} as const); +const recordingUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun does not expose an exact-owner screen-recording runtime.', +} as const); +const headlessUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Headless boot is unavailable for provider-owned devices.', +} as const); +/** Also read outside this module's own facts assembly: the owner's `inspectFacts` reports this + * for every operation when the request names a device with no matching live session at all. */ +export const liveSessionUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'Limrun requires a matching live provider session for this device.', +} as const); +const prepareUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Apple runner preparation is unavailable for Limrun-owned devices.', +} 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', + hint: 'Limrun open requires a Limrun-owned iOS simulator or Android emulator.', +} as const); +const closeTargetUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun close requires a Limrun-owned iOS simulator or Android emulator.', +} as const); +const runtimeHintsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Runtime hints are not applied to provider-owned devices.', +} as const); +const portReverseUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun port reverse requires an active Android Limrun session.', +} as const); + +/** Also read outside this module's own facts assembly: the owner's `bind` needs the same + * deployment options its facts do. */ +export function deploymentOptions( + options: LimrunPlatformRuntimeOwnerOptions, +): LimrunAppDeploymentRuntimeOptions { + return { ...options, isSessionActive: options.hasLiveSession }; +} + +/** Also read outside this module's own facts assembly: the owner's `inspectFacts` reports this + * for the not-live-session fallback too. */ +export function limrunLifecycleFacts(device: DeviceInfo, live: boolean) { + const openTarget = limrunOpenTargetFact(device, live); + const closeTarget = limrunCloseTargetFact(device, live); + const portReverse = limrunPortReverseFact(device, live); + return applicationLifecycleOperationFacts({ + resolveOpenTarget: openTarget, + prepareApplicationOpen: openTarget, + openApplication: openTarget, + applyRuntimeHints: runtimeHintsUnavailable, + clearRuntimeHints: runtimeHintsUnavailable, + closeApplication: closeTarget, + finalizeApplicationClose: closeTarget, + prepareAppleRunner: prepareUnavailable, + configureProviderPortReverse: portReverse, + }); +} + +function limrunOpenTargetFact(device: DeviceInfo, live: boolean) { + return isSupportedLimrunAppLogDevice(device) + ? live + ? available + : liveSessionUnavailable + : openTargetUnavailable; +} + +function limrunCloseTargetFact(device: DeviceInfo, live: boolean) { + return isSupportedLimrunAppLogDevice(device) + ? live + ? available + : liveSessionUnavailable + : closeTargetUnavailable; +} + +function limrunPortReverseFact(device: DeviceInfo, live: boolean) { + if (!live) return liveSessionUnavailable; + return device.platform === 'android' ? available : portReverseUnavailable; +} + +export function limrunAppLogFacts( + options: LimrunPlatformRuntimeOwnerOptions, + device: DeviceInfo, +): RuntimeFacts { + const deployment = limrunAppDeploymentFacts(deploymentOptions(options), device); + const isAndroid = device.platform === 'android'; + const customSnapshotFact = + isIosFamily(device) && device.kind === 'simulator' ? available : customSnapshotUnavailable; + return Object.freeze({ + device: { + family: device.platform, + ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + providerMode: 'provider-runtime', + }, + operations: { + appLogInspect: available, + appLogDoctor: available, + appLogStart: available, + appLogReattach: available, + appLogCleanup: available, + ...deployment, + appState: isAndroid ? available : iosAppStateUnavailable, + networkDump: available, + screenRecordingStart: recordingUnavailable, + screenRecordingReattach: recordingUnavailable, + screenRecordingCleanup: recordingUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: customSnapshotFact, + withoutActiveApp: available, + }), + ...screenshotRuntimeOperationFacts({ capture: available }), + // Provider ownership is authoritative: no native text reading is exposed, so text waits + // on a Limrun-owned device poll the canonical tree rather than borrowing Apple's. + ...selectorObservationRuntimeOperationFacts({ + findText: customSnapshotUnavailable, + findSelector: customSnapshotUnavailable, + }), + ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), + // Focus rides the same provider interactor the captures do, and a live-session Limrun + // device always has one, so it is available wherever a capture is. + ...limrunInteractionOperationFacts(), + ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + ...limrunNavigationOperationFacts(device), + ...limrunKeyboardOperationFacts(device), + ensureReady: available, + bootTarget: available, + bootTargetHeadless: headlessUnavailable, + listApps: available, + shutdownTarget: { + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun owns the target lifecycle for provider-owned devices.', + }, + ...limrunLifecycleFacts(device, true), + }, + }); +} + +export function limrunAppLogRecoveryFacts( + options: LimrunPlatformRuntimeOwnerOptions, + device: DeviceInfo, +): RuntimeFacts { + const normalFacts = limrunAppLogFacts(options, device); + return Object.freeze({ + device: normalFacts.device, + operations: { + ...normalFacts.operations, + appLogInspect: liveSessionUnavailable, + appLogDoctor: liveSessionUnavailable, + appLogStart: liveSessionUnavailable, + appLogReattach: available, + appLogCleanup: available, + appState: liveSessionUnavailable, + networkDump: liveSessionUnavailable, + screenRecordingStart: liveSessionUnavailable, + screenRecordingReattach: liveSessionUnavailable, + screenRecordingCleanup: liveSessionUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: liveSessionUnavailable, + customActions: liveSessionUnavailable, + withoutActiveApp: liveSessionUnavailable, + }), + ...screenshotRuntimeOperationFacts({ capture: liveSessionUnavailable }), + ...selectorObservationRuntimeOperationFacts({ + findText: liveSessionUnavailable, + findSelector: liveSessionUnavailable, + }), + ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), + ...limrunInteractionOperationFacts(liveSessionUnavailable), + ...limrunNavigationOperationFacts(device, liveSessionUnavailable), + ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), + ensureReady: liveSessionUnavailable, + bootTarget: liveSessionUnavailable, + bootTargetHeadless: liveSessionUnavailable, + listApps: liveSessionUnavailable, + deployApp: liveSessionUnavailable, + materializeAppSource: liveSessionUnavailable, + deployMaterializedApp: liveSessionUnavailable, + sendPushNotification: liveSessionUnavailable, + shutdownTarget: liveSessionUnavailable, + ...limrunLifecycleFacts(device, false), + }, + }); +} 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 000000000..ad0291234 --- /dev/null +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -0,0 +1,128 @@ +import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; +import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test } from 'vitest'; +import { 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 = bindAdmittedProviderInteractorOperations({ + device: androidTvDevice, + signal: new AbortController().signal, + resolveInteractor: (runner) => getInteractor(androidTvDevice, runner), + facts, + }); + + 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 = bindAdmittedProviderInteractorOperations({ + device: iosDevice, + signal: new AbortController().signal, + resolveInteractor: () => ({}) as unknown as Interactor, + facts, + }); + + 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 e4a21abf4..69d05b80c 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -1,18 +1,50 @@ +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { bindProviderFocusInteractor, focusRuntimeOperationFacts, } from '@agent-device/contracts/focus-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; +import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { bindProviderScreenshotInteractor } from '@agent-device/contracts/screenshot-runtime'; import { bindProviderSnapshotInteractor } from '@agent-device/contracts/snapshot-runtime'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import { bindProviderTypeTextInteractor, typeTextRuntimeOperationFacts, } from '@agent-device/contracts/type-text-runtime'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; -import type { RuntimeOperationUnavailability } from '@agent-device/contracts/platform'; +import type { RuntimeOperationUnavailability } from '@agent-device/contracts/platform-runtime'; 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 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); 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); /** * The interactor-backed interaction cells a live Limrun session serves: everything here rides @@ -47,3 +79,56 @@ 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 }), + }); +} + +/** + * `keyboard` (status/dismiss/enter) shares one cell per session: the Android leg rides the same + * interactor factory `limrunNavigationOperationFacts` above describes; the iOS leg has no tested + * provider keyboard behavior, so it stays unavailable. + */ +export function limrunKeyboardOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? (device.platform === 'android' ? available : keyboardUnavailableIos); + return Object.freeze({ + ...keyboardRuntimeOperationFacts({ status: cell, dismiss: cell, enter: cell }), + }); +} diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 794c2459f..2062b5a4d 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 1db4051e6..b139e68c6 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -8,10 +8,16 @@ import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { bindProviderFocusInteractor, focusRuntimeOperationFacts, } from '@agent-device/contracts/focus-runtime'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; +import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import { type DeviceBinding, type RuntimeFacts, @@ -128,6 +134,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, @@ -225,6 +264,32 @@ 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) : {}), + ...bindAdmittedProviderInteractorOperations({ + ...resolver, + facts: facts.operations, + }), + }; +} + function bindWebDriverPlatformRuntime( options: WebDriverPlatformRuntimeOptions, device: DeviceInfo, @@ -244,34 +309,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, { @@ -335,6 +373,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, @@ -359,6 +404,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 @@ -388,6 +440,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 843f588e1..b7f41161f 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 75202897e..b08709782 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/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 3ad04b620..45be501bc 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -61,6 +61,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/audio-probe-result', '@agent-device/contracts/audio-probe-support', '@agent-device/contracts/back-mode', + '@agent-device/contracts/back-runtime', '@agent-device/contracts/capture', '@agent-device/contracts/click-button', '@agent-device/contracts/client', @@ -78,15 +79,19 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/gesture-normalization', '@agent-device/contracts/gesture-plan', '@agent-device/contracts/gesture-plan-types', + '@agent-device/contracts/home-runtime', '@agent-device/contracts/interaction', '@agent-device/contracts/interaction-error', '@agent-device/contracts/interaction-guarantees', + '@agent-device/contracts/interactor-operation-catalog', '@agent-device/contracts/interactor-types', + '@agent-device/contracts/keyboard-runtime', '@agent-device/contracts/logs-runtime-plan', '@agent-device/contracts/navigation', '@agent-device/contracts/network-runtime', '@agent-device/contracts/network-runtime-plan', '@agent-device/contracts/observability', + '@agent-device/contracts/orientation-runtime', '@agent-device/contracts/platform', '@agent-device/contracts/platform-module', '@agent-device/contracts/platform-runtime', @@ -110,6 +115,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/snapshot-runtime', '@agent-device/contracts/startup-recovery-fence', '@agent-device/contracts/tv-remote', + '@agent-device/contracts/tv-remote-runtime', '@agent-device/contracts/type-text-runtime', '@agent-device/contracts/viewport-runtime', '@agent-device/contracts/wait', diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 3ea4849bd..9c8a07637 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 265a2302c..fc329f821 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/audio-probe-support'; 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 196509a8f..13bafc85d 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -37,6 +37,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 86ecf0d8a..c8738cc79 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 48b0433f7..62d1f288a 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 3cc10e7df..f7eca6bfb 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 02afe230f..deffa930c 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 6dd6ba2c0..c1825fe1b 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 6348e3123..bb09d9fd1 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 256f2a9fe..f71ff49d3 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 2245f9a1a..b9b31ed80 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 fe85766a8..ebe339dac 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -30,14 +30,19 @@ import { inventoryUse } from '@agent-device/contracts/platform-module'; import { appsRuntimeUse, appStateRuntimeUses, + backRuntimeUse, deviceBootRuntimeUses, findRuntimePlanUses, focusRuntimeUse, + homeRuntimeUse, + keyboardRuntimePlanUses, + orientationRuntimeUse, screenshotRuntimePlanUses, selectorCaptureRuntimePlanUses, selectorTextCaptureRuntimePlanUses, shutdownTargetUse, snapshotRuntimePlanUses, + tvRemoteRuntimeUse, typeTextRuntimeUse, viewportRuntimeUse, waitSelectorCaptureRuntimePlanUses, @@ -228,7 +233,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 = {}; @@ -251,7 +255,13 @@ const ALL_DEVICE_COMMAND_CAPABILITY = { const NO_PLATFORM_EXECUTION = { kind: 'none' } as const; const LEGACY_PLATFORM_EXECUTION = { kind: 'legacy' } as const; -const GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS = { +/** + * The daemon/recording traits every generic-route mutating command shares, migrated or not. + * Split from the legacy execution pair (`dispatch`/`capability`, see + * {@link LEGACY_LINUX_DEVICE_EXECUTION}) so a migrated descriptor spreads this alone instead of + * hand-expanding it minus the two fields migration strips. + */ +const GENERIC_MUTATING_COMMAND_TRAITS = { recordsSessionAction: true, recordingEffect: 'mutates-app', deviceClaimPolicy: 'require-owner', @@ -260,8 +270,6 @@ const GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS = { refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, } as const satisfies Pick< @@ -270,12 +278,23 @@ const GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS = { | 'recordingEffect' | 'deviceClaimPolicy' | 'daemon' - | 'dispatch' - | 'capability' | 'timeoutPolicy' | 'batchable' >; +/** + * The legacy `dispatch`/`capability` pair a still-unmigrated generic-route mutating command + * carries alongside {@link GENERIC_MUTATING_COMMAND_TRAITS}; migration strips both together (one + * owner fact replaces the capability bucket, one bound operation replaces the dispatch leaf). + */ +const LEGACY_LINUX_DEVICE_EXECUTION = { + dispatch: {}, + capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, +} as const satisfies Pick< + Extract, + 'dispatch' | 'capability' +>; + // click/fill/press/longpress differ only in their timeout budget and response // shaping: same owner file, same pre-dispatch target identity, same interaction // route and dialog guard, same device buckets, and the same session-bound claim @@ -763,6 +782,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: { @@ -770,15 +792,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', @@ -1232,14 +1248,13 @@ 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 postActionObservation + // timeout trait it kept is admission-independent, like `type` kept its dialog guard. + ...GENERIC_MUTATING_COMMAND_TRAITS, timeoutPolicy: postActionObservationTimeoutPolicy('back', DEFAULT_TIMEOUT_POLICY), postActionObservation: postActionObservation('back'), - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [backRuntimeUse] }, }, { name: 'gesture', @@ -1265,66 +1280,38 @@ 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, - }, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // 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. + ...GENERIC_MUTATING_COMMAND_TRAITS, + platformExecution: { kind: 'device-runtime', uses: [homeRuntimeUse] }, }, { name: 'tv-remote', - deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'tvRemote' }, frameworkTier: 'extended', - recordsSessionAction: true, - recordingEffect: 'mutates-app', - daemon: { - route: 'generic', - 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, + // 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. + ...GENERIC_MUTATING_COMMAND_TRAITS, + platformExecution: { kind: 'device-runtime', uses: [tvRemoteRuntimeUse] }, }, { name: 'orientation', - deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', - recordsSessionAction: true, - recordingEffect: 'mutates-app', - daemon: { - route: 'generic', - 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, + // 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. + ...GENERIC_MUTATING_COMMAND_TRAITS, + platformExecution: { kind: 'device-runtime', uses: [orientationRuntimeUse] }, }, { name: 'scroll', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', - ...GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS, + ...GENERIC_MUTATING_COMMAND_TRAITS, + ...LEGACY_LINUX_DEVICE_EXECUTION, timeoutPolicy: postActionObservationTimeoutPolicy('scroll', DEFAULT_TIMEOUT_POLICY), postActionObservation: postActionObservation('scroll'), platformExecution: LEGACY_PLATFORM_EXECUTION, @@ -1353,18 +1340,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', // R40 retires this command's capability bucket and its `dispatch` leaf together: admission is - // the owner's `focusPoint` 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, - }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, - batchable: true, + // the owner's `focusPoint` fact, and the only execution is the bound operation. + ...GENERIC_MUTATING_COMMAND_TRAITS, platformExecution: { kind: 'device-runtime', uses: [focusRuntimeUse] }, }, { diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 19525d749..b705b65fc 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/tv-remote'; -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 391e17e00..5ed26322d 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: async () => ({ kind: 'ime-probe', ...(await 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 8f8b07c79..f043b318e 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 { kind: 'acknowledged' }; + }, + 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 d8ba145f5..92f063046 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 000000000..3dd6cea79 --- /dev/null +++ b/src/daemon/__tests__/back-runtime.test.ts @@ -0,0 +1,185 @@ +import { expect, test, vi } from 'vitest'; +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type DeviceRuntimeGateway, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + backRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +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'; + +// File-scoped id, not the widely shared 'ios-simulator' literal: this owner binding's +// `local-family` kind reaches the real on-disk device-claim admission (`require-owner` +// policy), so a shared id risks a cross-file claim collision under parallel test-file +// execution (#1955 review). +const appleDevice = { + id: 'back-runtime-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 000000000..5409ffba0 --- /dev/null +++ b/src/daemon/__tests__/home-runtime.test.ts @@ -0,0 +1,152 @@ +import { expect, test, vi } from 'vitest'; +import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type DeviceRuntimeGateway, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + homeRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +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 000000000..0c9c955b8 --- /dev/null +++ b/src/daemon/__tests__/keyboard-runtime.test.ts @@ -0,0 +1,487 @@ +import { expect, test, vi } from 'vitest'; +import { + keyboardRuntimeOperationFacts, + type KeyboardDismissResult, + type KeyboardEnterResult, + type KeyboardStatusResult, +} from '@agent-device/contracts/keyboard-runtime'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + keyboardDismissUse, + keyboardEnterUse, + keyboardStatusUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; + +// File-scoped ids, not the widely shared 'emulator-5554'/'ios-simulator' literals: these owner +// bindings' `local-family` kind reaches the real on-disk device-claim admission (`require-owner` +// policy), so a shared id risks a cross-file claim collision under parallel test-file execution +// (#1955 review). +const androidDevice: DeviceInfo = { + id: 'keyboard-runtime-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: 'keyboard-runtime-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 () => ({ + kind: 'ime-probe', + 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({ + kind: 'mechanism', + 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({ + kind: 'mechanism', + 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({ + kind: 'mechanism', + 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({ + kind: 'mechanism', + 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, + }); + harness.keyboardDismiss.mockResolvedValue({ kind: 'acknowledged' }); + + 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({ + kind: 'ime-probe', + 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 000000000..15f46b16b --- /dev/null +++ b/src/daemon/__tests__/orientation-runtime.test.ts @@ -0,0 +1,235 @@ +import { expect, test, vi } from 'vitest'; + +// `orientation` carries `androidBlockingDialogGuard: true` (like every other generic-route leaf +// in this migration), so this file's Android device reaching the real request router below hits +// the real `adb`-backed `ensureNoAndroidBlockingDialogReady` check. Stub the owner-level dialog +// probe the same way `request-router-android-modal.test.ts` does, so that check short-circuits to +// "clear" without spawning `adb` — matching the daemon's own real guard seam instead of dodging +// the device platform this test is named for (#1955 review). +vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAndroidBlockingDialogFocus: vi.fn(async () => null), + }; +}); + +import { + orientationRuntimeOperationFacts, + type SetOrientationResult, +} from '@agent-device/contracts/orientation-runtime'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type DeviceRuntimeGateway, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + orientationRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +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'; + +// File-scoped id, not a shared literal: this owner binding's `local-family` kind reaches the +// real on-disk device-claim admission (`require-owner` policy), so a shared id risks a +// cross-file claim collision under parallel test-file execution (#1955 review). +const testDevice = { + id: 'orientation-runtime-device', + 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: testDevice }); + 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(testDevice), providerMode: 'local' }, + operations: { setOrientation: fact } as RuntimeFacts['operations'], + }; + const binding = { + device: testDevice, + 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: testDevice, + positionals: ['landscape-left'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.bindDevice).toHaveBeenCalledWith(testDevice, 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: testDevice, + 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: testDevice, + 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: testDevice, + 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: testDevice }); + 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 310a11f3c..59c38deb0 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 a78c0750c..6ba338bc6 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 9a8cb1bea..e965159d6 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 2c6f151aa..8d99c58c2 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 bbe3f8ab1..2aac87504 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 000000000..622509330 --- /dev/null +++ b/src/daemon/__tests__/tv-remote-runtime.test.ts @@ -0,0 +1,285 @@ +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type DeviceRuntimeGateway, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + tvRemoteRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +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(); +}); + +// Pins the wire response for a non-TV target on the two platforms that keep their own capability +// hint text (`packages/platform-apple/src/runtime.test.ts` and +// `packages/platform-android/src/runtime.test.ts` prove these are the exact strings those owners' +// facts produce). Before this migration, the daemon's own generic capability gate +// (`requireCommandSupported(command, device, { hint: true })` in the retired +// `ensureGenericCommandReady`) produced the identical shape — a generic " is not +// supported on this device" message plus the owner-specific hint — for every device that reached +// dispatch; `handleTvRemoteCommand`'s own internal `device.target !== 'tv'` check with the unified +// "supported only on TV targets" message was unreachable from that gate and only ever exercised by +// a test calling `dispatchCommand` directly, bypassing the daemon layer entirely. +test.each([ + [ + 'iOS simulator (not tvOS)', + { id: 'ios-sim', name: 'iPhone', platform: 'apple', kind: 'simulator', booted: true } as const, + 'tv-remote is supported only on tvOS devices.', + ], + [ + 'Android emulator (mobile target)', + { + // File-scoped id, not the widely shared 'emulator-5554' literal: this owner binding's + // `local-family` kind reaches the real on-disk device-claim admission (`require-owner` + // policy), so a shared id risks a cross-file claim collision under parallel test-file + // execution (#1955 review). + id: 'tv-remote-runtime-5554', + name: 'Pixel', + platform: 'android', + kind: 'emulator', + target: 'mobile', + booted: true, + } as const, + 'tv-remote is supported only on Android TV targets.', + ], +])( + 'rejects %s with its owner-specific hint, generic message preserved', + async (_name, device, hint) => { + const fact: RuntimeOperationFact = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint, + }); + const facts: RuntimeFacts = { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: { tvRemote: fact } as RuntimeFacts['operations'], + }; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn() as unknown as BindDeviceRuntime; + + const resolved = await resolveBoundTvRemoteRuntime({ + device, + positionals: ['down'], + inspectFacts, + bindDevice, + }); + + expect(resolved).toEqual({ + ok: false, + response: { + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'tv-remote is not supported on this device', + hint, + }, + }, + }); + expect(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 000000000..f9cebd5d1 --- /dev/null +++ b/src/daemon/back-runtime.ts @@ -0,0 +1,57 @@ +import type { BackInput } from '@agent-device/contracts/back-runtime'; +import type { BackMode } from '@agent-device/contracts/interaction'; +import { backRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +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). Typed off `typeof backRuntimeUse` rather than a + * hand-restated operations shape, so a change to what `back` binds can't silently drift here. + */ +async function executeBack( + runtime: BoundDeviceRuntime, + 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 8a28a576c..e9790fd17 100644 --- a/src/daemon/focus-runtime.ts +++ b/src/daemon/focus-runtime.ts @@ -6,7 +6,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. */ @@ -35,20 +35,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 9c207a7c9..15c5c0d40 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 2f0f33194..5974d97be 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -358,6 +358,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 e5117d8ce..9a0d832cf 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -90,6 +90,13 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts { } }); -test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves the frame', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'kb'; - const device: SessionState['device'] = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(device); - mockDispatch.mockResolvedValue({}); - const logPath = path.join(os.tmpdir(), 'daemon.log'); - - // dismiss mutates the device → frame expires. - sessionStore.set(sessionName, makeSession(sessionName, device)); - await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'keyboard', - positionals: ['dismiss'], - flags: {}, - }, - sessionName, - logPath, - sessionStore, - invoke: noopInvoke, - }); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); - - // status is a read-only probe → frame preserved (undefined === active). - sessionStore.set(sessionName, makeSession(sessionName, device)); - await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'keyboard', - positionals: ['status'], - flags: {}, - }, - sessionName, - logPath, - sessionStore, - invoke: noopInvoke, - }); - expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); -}); - test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async () => { const sessionStore = makeSessionStore(); const selectedDevice: SessionState['device'] = { @@ -287,57 +238,6 @@ test('clipboard requires an active session or explicit device selector', async ( } }); -test('keyboard requires an active session or explicit device selector', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'keyboard', - positionals: ['status'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch( - /keyboard requires an active session or an explicit device selector/i, - ); - } -}); - -test('keyboard dismiss requires active iOS session for explicit selectors', async () => { - const sessionStore = makeSessionStore(); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'keyboard', - positionals: ['dismiss'], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - expect(response.error.message).toMatch(/requires an active session/i); - } -}); - test('clipboard rejects unsupported iOS physical devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index 6ba4f233a..b13401855 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 d9fe8876c..6569b10e8 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -150,6 +150,13 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts Promise }>, +) { + const facts: 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: + overrides?.keyboardDismiss ?? + (async () => ({ kind: 'ime-probe', 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('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves the frame', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'kb'; + const device: SessionState['device'] = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + 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)); + await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'keyboard', + positionals: ['dismiss'], + flags: {}, + }, + sessionName, + logPath, + sessionStore, + invoke: noopInvoke, + inspectFacts, + bindDevice, + }); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + + // status is a read-only probe → frame preserved (undefined === active). + sessionStore.set(sessionName, makeSession(sessionName, device)); + await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'keyboard', + positionals: ['status'], + flags: {}, + }, + sessionName, + logPath, + sessionStore, + invoke: noopInvoke, + inspectFacts, + bindDevice, + }); + expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); +}); + +// #1955 review: `runSessionOrSelectorDispatch` used to expire the frame only after a successful +// `execute`, so a rejecting/timed-out invocation left a stale frame active. ADR 0014 requires +// expiry immediately before the mutating call, with no success-only rollback. The inner assertion +// pins the exact pre-invocation seam — the frame must already be expired by the time the mutating +// call is reached, not just eventually after the whole dispatch settles. +test('keyboard dismiss expires the frame before the invocation runs, even when it rejects', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'kb-reject'; + const device: SessionState['device'] = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(device); + const logPath = path.join(os.tmpdir(), 'daemon.log'); + const { inspectFacts, bindDevice } = keyboardCapableRuntime(device, { + keyboardDismiss: () => { + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + return Promise.reject(new AppError('COMMAND_FAILED', 'runner timed out')); + }, + }); + + sessionStore.set(sessionName, makeSession(sessionName, device)); + await expect( + handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'keyboard', + positionals: ['dismiss'], + flags: {}, + }, + sessionName, + logPath, + sessionStore, + invoke: noopInvoke, + inspectFacts, + bindDevice, + }), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); +}); + +test('keyboard requires an active session or explicit device selector', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'keyboard', + positionals: ['status'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch( + /keyboard requires an active session or an explicit device selector/i, + ); + } +}); + +test('keyboard dismiss requires active iOS session for explicit selectors', async () => { + const sessionStore = makeSessionStore(); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'keyboard', + positionals: ['dismiss'], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + expect(response.error.message).toMatch(/requires an active session/i); + } +}); diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 1f671311a..75da082fc 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -51,6 +51,13 @@ test('boot rejects --headless outside Android directly', async () => { 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' }, @@ -139,6 +146,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-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts new file mode 100644 index 000000000..556b297f7 --- /dev/null +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -0,0 +1,248 @@ +import { dispatchCommand } from '../../core/dispatch.ts'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import { contextFromFlags } from '../context.ts'; +import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; +import { errorResponse, requireCommandSupported } from './response.ts'; +import { recordSessionAction } from './handler-utils.ts'; +import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; +import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; +import { expireRefFrame } from '../ref-frame.ts'; +import { + resolveAndroidPackageForOpen, + resolveSessionAppBundleIdForTarget, +} from '../../platform-runtime-open-target.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; + +/** + * What `runSessionOrSelectorDispatch`'s `prepare` thunk reports: either the early-exit response an + * admission refusal produces (nothing mutated yet, so the frame stays untouched), or the + * invocation to run once the ref frame is expired. Splitting admission/preparation from invocation + * lets the frame expire immediately before the mutating call (ADR 0014) regardless of whether that + * call later succeeds, rejects, or times out — there is no success-only rollback. + */ +type SessionCommandPrepareOutcome = + | Readonly<{ ok: false; response: DaemonResponse }> + | Readonly<{ ok: true; execute: () => Promise | void> }>; + +/** + * The one orchestration every session/selector-route leaf shares: guard, resolve the device, + * admit-then-prepare via the caller's own strategy, expire the ref frame if the command mutates + * (immediately before the prepared invocation runs, never after), derive and record the next + * session. `prepare` is where the two strategies session-route commands use today diverge — + * {@link legacySessionDispatchExecute} for a still-legacy command (capability gate, then + * `dispatchCommand`), or a bind-and-execute thunk like `keyboard`'s below for a migrated one — + * everything around it is identical either way, so it lives here once instead of once per command. + */ +// fallow-ignore-next-line complexity +async function runSessionOrSelectorDispatch(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + command: string; + positionals: string[]; + recordPositionals?: string[]; + deriveNextSession?: ( + session: SessionState, + result: Record | void, + device: DeviceInfo, + ) => Promise | SessionState; + prepare: ( + device: DeviceInfo, + session: SessionState | undefined, + ) => Promise; +}): Promise { + const { + req, + sessionName, + sessionStore, + command, + positionals, + recordPositionals, + deriveNextSession, + prepare, + } = params; + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + const guard = requireSessionOrExplicitSelector(command, session, flags); + if (guard) return guard; + + const device = await resolveCommandDevice({ + session, + flags, + ensureReady: true, + }); + const prepared = await prepare(device, session); + if (!prepared.ok) return prepared.response; + + // ADR 0014 side-effect seam for session/selector-route leaves (keyboard + // dismiss/enter/return, push, trigger-app-event). Expire the frame immediately before the + // mutating invocation runs — not after it resolves — when the classification says this + // request mutates; keyboard status/get resolve to `preserve` and leave the frame untouched. + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } + + const result = await prepared.execute(); + + if (session) { + const nextSession = deriveNextSession + ? await deriveNextSession(session, result, device) + : session; + recordSessionAction(sessionStore, nextSession, req, command, result ?? {}, { + positionals: recordPositionals ?? positionals, + }); + if (nextSession !== session) { + sessionStore.set(sessionName, nextSession); + } + } + return { ok: true, data: result ?? {} }; +} + +/** The still-legacy `prepare` thunk: the capability gate is admission (nothing mutated yet if it + * refuses); `dispatchCommand` is the invocation, deferred until `runSessionOrSelectorDispatch` has + * expired the ref frame. Every remaining unmigrated session-route command passes this until its + * own migration replaces it with a bind-and-execute thunk, same as `keyboard`'s + * {@link handleKeyboardCommand} below. */ +function legacySessionDispatchExecute( + command: string, + positionals: string[], + req: DaemonRequest, + logPath: string, +): ( + device: DeviceInfo, + session: SessionState | undefined, +) => Promise { + return async (device, session) => { + const unsupported = requireCommandSupported(command, device); + if (unsupported) return { ok: false, response: unsupported }; + return { + ok: true, + execute: () => + dispatchCommand(device, command, positionals, req.flags?.out, { + ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + }), + }; + }; +} + +/** + * 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 || 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.', + ); +} + +/** + * `keyboard`'s migrated `prepare` thunk (ADR 0019 §9): bind whichever action-selected use the + * parsed action names — admission only, no device I/O yet — and defer the bound runtime's own + * `execute` as the invocation `runSessionOrSelectorDispatch` runs after expiring the frame. + * Replaces the resolve-then-record shape `runSessionOrSelectorDispatch` now owns — this only + * supplies what the generic route's `dispatchCommand` cannot: the bound runtime and the + * action-specific execution context. + */ +function keyboardSessionExecute( + positionals: string[], + inspectFacts: InspectDeviceRuntimeFacts | undefined, + bindDevice: BindDeviceRuntime | undefined, + req: DaemonRequest, + logPath: string, +): ( + device: DeviceInfo, + session: SessionState | undefined, +) => Promise { + return async (device, session) => { + const bound = await resolveBoundKeyboardRuntime({ + device, + positionals, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return { ok: false, response: bound.response }; + const dispatchContext = { + ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + surface: session?.surface, + }; + return { ok: true, execute: () => bound.execute(dispatchContext) }; + }; +} + +export async function handleKeyboardCommand(params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; +}): Promise { + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const positionals = req.positionals ?? []; + + const foregroundGuard = requireForegroundIosKeyboardSession( + sessionStore.get(sessionName), + positionals[0]?.trim().toLowerCase(), + req.flags ?? {}, + ); + if (foregroundGuard) return foregroundGuard; + + return await runSessionOrSelectorDispatch({ + req, + sessionName, + sessionStore, + command: PUBLIC_COMMANDS.keyboard, + positionals, + prepare: keyboardSessionExecute(positionals, inspectFacts, bindDevice, req, logPath), + }); +} + +export async function handleTriggerAppEventCommand(params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; +}): Promise { + const { req, sessionName, logPath, sessionStore } = params; + const positionals = req.positionals ?? []; + return await runSessionOrSelectorDispatch({ + req, + sessionName, + sessionStore, + command: PUBLIC_COMMANDS.triggerAppEvent, + positionals, + prepare: legacySessionDispatchExecute( + PUBLIC_COMMANDS.triggerAppEvent, + positionals, + req, + logPath, + ), + deriveNextSession: async (session, result) => { + const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; + const nextAppBundleId = eventUrl + ? ((await resolveSessionAppBundleIdForTarget( + session.device, + eventUrl, + session.appBundleId, + resolveAndroidPackageForOpen, + )) ?? session.appBundleId) + : session.appBundleId; + return { + ...session, + appBundleId: nextAppBundleId, + }; + }, + }); +} diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 472d211e2..0278088e0 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,8 +1,8 @@ import { dispatchCommand } from '../../core/dispatch.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; -import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { publicPlatformString } from '@agent-device/kernel/device'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; import { handleReleaseMaterializedPathsCommand } from './session-app-source-deployment.ts'; @@ -14,9 +14,9 @@ import { requireRuntimeBinding, requireRuntimeFacts } from './session-runtime-ad import { handleOpenCommand } from './session-open.ts'; import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts'; import { - resolveAndroidPackageForOpen, - resolveSessionAppBundleIdForTarget, -} from '../../platform-runtime-open-target.ts'; + handleKeyboardCommand, + handleTriggerAppEventCommand, +} from './session-selector-dispatch.ts'; import { handleCloseCommand } from './session-close.ts'; import { handleSessionAppDeploymentCommand } from './session-app-deployment-route.ts'; import { runBatchCommands } from './session-batch.ts'; @@ -27,9 +27,7 @@ import { handleSessionReplayCommands } from './session-replay.ts'; import { handleSessionScriptPublication } from './session-script-publication.ts'; import { handleDoctorCommand } from './session-doctor.ts'; import { handlePrepareCommand } from './session-prepare.ts'; -import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import type { DescriptorSessionRouteCommandName } from '../../core/command-descriptor/registry.ts'; -import { expireRefFrame } from '../ref-frame.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; import type { @@ -42,69 +40,6 @@ import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; import type { PlatformRequestScope } from '@agent-device/contracts/platform'; -// fallow-ignore-next-line complexity -async function runSessionOrSelectorDispatch(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - command: string; - positionals: string[]; - recordPositionals?: string[]; - deriveNextSession?: ( - session: SessionState, - result: Record | void, - device: DeviceInfo, - ) => Promise | SessionState; -}): Promise { - const { - req, - sessionName, - logPath, - sessionStore, - command, - positionals, - recordPositionals, - deriveNextSession, - } = params; - const session = sessionStore.get(sessionName); - const flags = req.flags ?? {}; - const guard = requireSessionOrExplicitSelector(command, session, flags); - if (guard) return guard; - - const device = await resolveCommandDevice({ - session, - flags, - ensureReady: true, - }); - const unsupported = requireCommandSupported(command, device); - if (unsupported) return unsupported; - - // ADR 0014 side-effect seam for session/selector-route leaves (keyboard - // dismiss/enter/return, push, trigger-app-event). Expire the frame before the - // dispatch when the classification says this request mutates; keyboard - // status/get resolve to `preserve` and leave the frame untouched. - if (session && resolveRefFrameEffect(req) === 'may-invalidate') { - expireRefFrame(session); - } - - const result = await dispatchCommand(device, command, positionals, req.flags?.out, { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - }); - if (session) { - const nextSession = deriveNextSession - ? await deriveNextSession(session, result, device) - : session; - recordSessionAction(sessionStore, nextSession, req, command, result ?? {}, { - positionals: recordPositionals ?? positionals, - }); - if (nextSession !== session) { - sessionStore.set(sessionName, nextSession); - } - } - return { ok: true, data: result ?? {} }; -} - // fallow-ignore-next-line complexity async function handleClipboardCommand(params: { req: DaemonRequest; @@ -252,59 +187,6 @@ 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(); - 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.', - ); - } - } - return await runSessionOrSelectorDispatch({ - req, - sessionName, - logPath, - sessionStore, - command: PUBLIC_COMMANDS.keyboard, - positionals: req.positionals ?? [], - }); -} - -async function handleTriggerAppEventCommand(params: SessionCommandParams): Promise { - const { req, sessionName, logPath, sessionStore } = params; - return await runSessionOrSelectorDispatch({ - req, - sessionName, - logPath, - sessionStore, - command: PUBLIC_COMMANDS.triggerAppEvent, - positionals: req.positionals ?? [], - deriveNextSession: async (session, result) => { - const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; - const nextAppBundleId = eventUrl - ? ((await resolveSessionAppBundleIdForTarget( - session.device, - eventUrl, - session.appBundleId, - resolveAndroidPackageForOpen, - )) ?? session.appBundleId) - : session.appBundleId; - return { - ...session, - appBundleId: nextAppBundleId, - }; - }, - }); -} - /** * Descriptor-driven exhaustive dispatch table for the daemon's `session` * route (mirrors `DISPATCH_HANDLERS` in src/core/dispatch.ts and diff --git a/src/daemon/home-runtime.ts b/src/daemon/home-runtime.ts new file mode 100644 index 000000000..800601042 --- /dev/null +++ b/src/daemon/home-runtime.ts @@ -0,0 +1,51 @@ +import type { HomeInput } from '@agent-device/contracts/home-runtime'; +import { homeRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +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). Typed off `typeof homeRuntimeUse` rather than a + * hand-restated operations shape, so a change to what `home` binds can't silently drift here. + */ +async function executeHome( + runtime: BoundDeviceRuntime, + 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 000000000..ebf712a8f --- /dev/null +++ b/src/daemon/keyboard-runtime.ts @@ -0,0 +1,242 @@ +import type { + KeyboardActionInput, + KeyboardDismissResult, +} from '@agent-device/contracts/keyboard-runtime'; +import { + keyboardDismissUse, + keyboardEnterUse, + keyboardStatusUse, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { + BoundDeviceRuntime, + RuntimeOperationKey, + RuntimeUse, +} from '@agent-device/contracts/platform-runtime'; +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, + type RuntimeAdmissionRequest, +} 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'; +} + +/** + * `dismiss`'s wire `platform` label is derived from which owner shape came back (#1955 review), + * not re-derived from the device the way `status`/`enter` still do — an owner can only ever + * produce its own {@link KeyboardDismissResult} kind, so this mapping can't disagree with reality. + */ +const KEYBOARD_DISMISS_PLATFORM_LABEL: Record< + KeyboardDismissResult['kind'], + 'android' | 'harmonyos' | 'ios' +> = { + 'ime-probe': 'android', + mechanism: 'ios', + acknowledged: 'harmonyos', +}; + +function keyboardActionInput(context: DaemonCommandContext): KeyboardActionInput { + return { + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place any of `keyboard`'s three action-selected uses admits and binds (R46): one + * `admitRuntimeUse` call, deferred to the caller's `execute` closure exactly like + * `resolveBoundGenericRuntime` does for the generic route — `runtime`'s type there is inferred + * from the resolved `use` instantiation, never restated by hand. Kept local to this file rather + * than folded into `resolveBoundGenericRuntime` itself: keyboard's execution shape + * (`(context) => …`) is the session route's, not the generic dispatcher's + * (`GenericPlatformExecution`) the other four leaves share. + */ +async function admitKeyboardAction< + 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 + >, + context: 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: (context) => execute(runtime, context) }; +} + +/** `keyboard status` — Android-only; every other owner refuses. */ +function executeKeyboardStatus( + runtime: BoundDeviceRuntime, + platform: 'android' | 'harmonyos' | 'ios', + context: DaemonCommandContext, +): Promise> { + return runtime.operations.keyboardStatus(keyboardActionInput(context)).then((state) => ({ + platform, + action: 'status', + visible: state.visible, + inputType: state.inputType, + type: state.type, + inputMethodPackage: state.inputMethodPackage, + focusedPackage: state.focusedPackage, + focusedResourceId: state.focusedResourceId, + inputOwner: state.inputOwner, + })); +} + +/** `keyboard dismiss`. */ +async function executeKeyboardDismiss( + runtime: BoundDeviceRuntime, + context: DaemonCommandContext, +): Promise> { + const result = await runtime.operations.keyboardDismiss(keyboardActionInput(context)); + const platform = KEYBOARD_DISMISS_PLATFORM_LABEL[result.kind]; + if (result.kind === 'mechanism') { + return { + platform, + action: 'dismiss', + wasVisible: result.wasVisible, + dismissed: result.dismissed, + visible: result.visible, + mechanism: result.mechanism, + ...successText(iosKeyboardDismissMessage(result.dismissed === true, result.mechanism)), + }; + } + if (result.kind === 'acknowledged') { + 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, + }; +} + +/** `keyboard enter`. */ +async function executeKeyboardEnter( + runtime: BoundDeviceRuntime, + 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') }; +} + +/** + * 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); + const { device, inspectFacts, bindDevice } = params; + if (action === 'status') { + return await admitKeyboardAction( + { command: 'keyboard status', device, use: keyboardStatusUse, inspectFacts, bindDevice }, + (runtime, context) => executeKeyboardStatus(runtime, platform, context), + ); + } + if (action === 'dismiss') { + return await admitKeyboardAction( + { command: 'keyboard dismiss', device, use: keyboardDismissUse, inspectFacts, bindDevice }, + (runtime, context) => executeKeyboardDismiss(runtime, context), + ); + } + return await admitKeyboardAction( + { command: 'keyboard enter', device, use: keyboardEnterUse, inspectFacts, bindDevice }, + (runtime, context) => executeKeyboardEnter(runtime, platform, context), + ); +} + +// 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 000000000..071dd0451 --- /dev/null +++ b/src/daemon/orientation-runtime.ts @@ -0,0 +1,68 @@ +import type { SetOrientationInput } from '@agent-device/contracts/orientation-runtime'; +import { parseDeviceRotation, type DeviceRotation } from '@agent-device/contracts/device'; +import { orientationRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +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). Typed off `typeof orientationRuntimeUse` + * rather than a hand-restated operations shape, so a change to what `setOrientation` binds can't + * silently drift here. + */ +async function executeSetOrientation( + runtime: BoundDeviceRuntime, + 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 378821d10..7ad73ea1e 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 = ( @@ -23,7 +25,7 @@ export type RuntimeAdmission = | Readonly<{ type: 'response'; response: DaemonFailureResponse }> | Readonly<{ type: 'runtime'; runtime: BoundDeviceRuntime }>; -type RuntimeAdmissionRequest = Readonly<{ +export type RuntimeAdmissionRequest = Readonly<{ /** Command wording for the default unsupported message, e.g. `open`, `runtime port-reverse`. */ command: string; device: DeviceInfo; @@ -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 000000000..7f807f8e5 --- /dev/null +++ b/src/daemon/tv-remote-runtime.ts @@ -0,0 +1,83 @@ +import type { TvRemoteInput } from '@agent-device/contracts/tv-remote-runtime'; +import { tvRemoteRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { parseTvRemoteButton, type TvRemoteButton } from '@agent-device/contracts/tv-remote'; +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