diff --git a/COMMAND_OWNERSHIP.md b/COMMAND_OWNERSHIP.md index 6d0d39b58..13312379d 100644 --- a/COMMAND_OWNERSHIP.md +++ b/COMMAND_OWNERSHIP.md @@ -59,6 +59,16 @@ Their semantics should live in `agent-device/commands` as they migrate. - `fill`: runtime command implemented for point, ref, and selector targets; the daemon fill dispatch calls the runtime. - `type`: runtime command implemented; daemon type dispatch calls the runtime. +- `open`: runtime `apps.open` implemented for typed app, bundle/package, + activity, URL, and relaunch targets. +- `close`: runtime `apps.close` implemented for optional app targets. +- `apps`: runtime `apps.list` implemented with typed app list filters. +- `appstate`: runtime `apps.state` implemented against backend state + primitives. +- `push`: runtime `apps.push` implemented with JSON and artifact/file inputs; + local file inputs remain command-policy gated. +- `trigger-app-event`: runtime `apps.triggerEvent` implemented with event name + and JSON payload validation. ## Boundary Requirements diff --git a/src/__tests__/runtime-apps.test.ts b/src/__tests__/runtime-apps.test.ts new file mode 100644 index 000000000..8de5bab30 --- /dev/null +++ b/src/__tests__/runtime-apps.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { + AgentDeviceBackend, + BackendAppEvent, + BackendOpenTarget, + BackendPushInput, +} from '../backend.ts'; +import { createLocalArtifactAdapter } from '../io.ts'; +import { createAgentDevice, localCommandPolicy, restrictedCommandPolicy } from '../runtime.ts'; + +test('runtime app commands call typed backend lifecycle primitives', async () => { + const calls: unknown[] = []; + const device = createAgentDevice({ + backend: createAppsBackend(calls), + artifacts: createLocalArtifactAdapter(), + policy: localCommandPolicy(), + }); + + const opened = await device.apps.open({ + session: 'default', + app: ' com.example.app ', + relaunch: true, + }); + assert.deepEqual(opened, { + kind: 'appOpened', + target: { app: 'com.example.app' }, + relaunch: true, + backendResult: { opened: true }, + message: 'Opened: com.example.app', + }); + + const closed = await device.apps.close({ app: 'com.example.app' }); + assert.equal(closed.kind, 'appClosed'); + + const listed = await device.apps.list({ filter: 'user-installed' }); + assert.deepEqual(listed.apps, [ + { + id: 'com.example.app', + name: 'Example', + bundleId: 'com.example.app', + }, + ]); + + const state = await device.apps.state({ app: 'com.example.app' }); + assert.deepEqual(state.state, { bundleId: 'com.example.app', state: 'foreground' }); + + const pushed = await device.apps.push({ + app: 'com.example.app', + input: { kind: 'json', payload: { aps: { alert: 'hello' } } }, + }); + assert.equal(pushed.inputKind, 'json'); + + const triggered = await device.apps.triggerEvent({ + name: 'example.ready', + payload: { source: 'test' }, + }); + assert.equal(triggered.name, 'example.ready'); + + assert.deepEqual(calls, [ + { + command: 'openApp', + target: { app: 'com.example.app' }, + options: { relaunch: true }, + session: 'default', + }, + { command: 'closeApp', app: 'com.example.app' }, + { command: 'listApps', filter: 'user-installed' }, + { command: 'getAppState', app: 'com.example.app' }, + { + command: 'pushFile', + target: 'com.example.app', + input: { kind: 'json', payload: { aps: { alert: 'hello' } } }, + }, + { + command: 'triggerAppEvent', + event: { name: 'example.ready', payload: { source: 'test' } }, + }, + ]); +}); + +test('runtime app push rejects local payload paths under restricted policy', async () => { + let pushCalled = false; + const device = createAgentDevice({ + backend: { + ...createAppsBackend([]), + pushFile: async () => { + pushCalled = true; + }, + }, + artifacts: createLocalArtifactAdapter(), + policy: restrictedCommandPolicy(), + }); + + await assert.rejects( + () => + device.apps.push({ + app: 'com.example.app', + input: { kind: 'path', path: '/tmp/payload.json' }, + }), + /Local input paths are not allowed/, + ); + assert.equal(pushCalled, false); +}); + +test('runtime app commands validate JSON payloads', async () => { + const device = createAgentDevice({ + backend: createAppsBackend([]), + artifacts: createLocalArtifactAdapter(), + policy: localCommandPolicy(), + }); + + await assert.rejects( + () => device.apps.triggerEvent({ name: 'bad event' }), + /Invalid apps\.triggerEvent name/, + ); + await assert.rejects( + () => + device.apps.push({ + app: 'com.example.app', + input: { kind: 'json', payload: [] as unknown as Record }, + }), + /JSON payload must be a JSON object/, + ); + await assert.rejects( + () => + device.apps.push({ + app: 'com.example.app', + input: { + kind: 'json', + payload: { count: 1n } as unknown as Record, + }, + }), + /JSON payload must be JSON-serializable/, + ); + await assert.rejects( + () => + device.apps.push({ + app: 'com.example.app', + input: { + kind: 'json', + payload: { toJSON: () => undefined } as unknown as Record, + }, + }), + /JSON payload must be JSON-serializable/, + ); + await assert.rejects( + () => + device.apps.push({ + app: 'com.example.app', + input: { + kind: 'json', + payload: { data: 'x'.repeat(8 * 1024) }, + }, + }), + /JSON payload exceeds 8192 bytes/, + ); + await assert.rejects( + () => + device.apps.triggerEvent({ + name: 'example.ready', + payload: { count: 1n } as unknown as Record, + }), + /payload for "example.ready" must be JSON-serializable/, + ); + await assert.rejects( + () => + device.apps.triggerEvent({ + name: 'example.ready', + payload: { toJSON: () => undefined } as unknown as Record, + }), + /payload for "example.ready" must be JSON-serializable/, + ); + await assert.rejects( + () => + device.apps.triggerEvent({ + name: 'example.ready', + payload: { data: 'x'.repeat(8 * 1024) }, + }), + /payload for "example.ready" exceeds 8192 bytes/, + ); + await assert.rejects( + () => + device.apps.push({ + app: 'com.example.app', + input: undefined as unknown as Parameters[0]['input'], + }), + /apps\.push requires an input/, + ); +}); + +function createAppsBackend(calls: unknown[]): AgentDeviceBackend { + return { + platform: 'ios', + openApp: async (context, target: BackendOpenTarget, options) => { + calls.push({ + command: 'openApp', + target, + options, + session: context.session, + }); + return { opened: true }; + }, + closeApp: async (_context, app) => { + calls.push({ command: 'closeApp', app }); + }, + listApps: async (_context, filter) => { + calls.push({ command: 'listApps', filter }); + return [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }]; + }, + getAppState: async (_context, app) => { + calls.push({ command: 'getAppState', app }); + return { bundleId: app, state: 'foreground' }; + }, + pushFile: async (_context, input: BackendPushInput, target) => { + calls.push({ command: 'pushFile', target, input }); + }, + triggerAppEvent: async (_context, event: BackendAppEvent) => { + calls.push({ command: 'triggerAppEvent', event }); + }, + }; +} diff --git a/src/__tests__/runtime-conformance.test.ts b/src/__tests__/runtime-conformance.test.ts index a784e5df9..d1fbc9a0d 100644 --- a/src/__tests__/runtime-conformance.test.ts +++ b/src/__tests__/runtime-conformance.test.ts @@ -31,6 +31,12 @@ test('command conformance suites run against a fixture backend', async () => { assert.equal(calls.includes('tap'), true); assert.equal(calls.includes('fill'), true); assert.equal(calls.includes('typeText'), true); + assert.equal(calls.includes('openApp'), true); + assert.equal(calls.includes('closeApp'), true); + assert.equal(calls.includes('listApps'), true); + assert.equal(calls.includes('getAppState'), true); + assert.equal(calls.includes('pushFile'), true); + assert.equal(calls.includes('triggerAppEvent'), true); }); test('assertCommandConformance throws when a suite fails', async () => { @@ -72,6 +78,26 @@ function createFixtureBackend(calls: string[]): AgentDeviceBackend { typeText: async () => { calls.push('typeText'); }, + openApp: async () => { + calls.push('openApp'); + }, + closeApp: async () => { + calls.push('closeApp'); + }, + listApps: async () => { + calls.push('listApps'); + return [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }]; + }, + getAppState: async (_context, app) => { + calls.push('getAppState'); + return { bundleId: app, state: 'foreground' }; + }, + pushFile: async () => { + calls.push('pushFile'); + }, + triggerAppEvent: async () => { + calls.push('triggerAppEvent'); + }, }; } diff --git a/src/__tests__/runtime-public.test.ts b/src/__tests__/runtime-public.test.ts index b8fb8a892..9ff24f1b0 100644 --- a/src/__tests__/runtime-public.test.ts +++ b/src/__tests__/runtime-public.test.ts @@ -37,6 +37,12 @@ const backend = { platform: 'ios', captureScreenshot: async () => {}, typeText: async () => {}, + openApp: async () => {}, + closeApp: async () => {}, + listApps: async () => [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }], + getAppState: async (_context, app: string) => ({ bundleId: app, state: 'foreground' as const }), + pushFile: async () => {}, + triggerAppEvent: async () => {}, } satisfies AgentDeviceBackend; const artifacts = { @@ -70,7 +76,7 @@ test('package root exposes command runtime skeleton', async () => { assert.equal(device.policy.allowLocalInputPaths, false); assert.equal(typeof device.capture.screenshot, 'function'); assert.equal(typeof device.interactions.click, 'function'); - assert.equal('apps' in device, false); + assert.equal(typeof device.apps.open, 'function'); const result = await device.capture.screenshot({}); assert.equal(result.path, '/tmp/path.png'); }); @@ -363,7 +369,7 @@ test('public backend, commands, io, and conformance subpaths are importable', () commandCatalog.some((entry) => entry.command === 'click' && entry.status === 'implemented'), true, ); - assert.equal(commandConformanceSuites.length, 3); + assert.equal(commandConformanceSuites.length, 4); assert.equal(typeof runCommandConformance, 'function'); assert.equal(target.name, 'fake'); }); @@ -415,6 +421,33 @@ test('command router dispatches implemented runtime commands and normalizes erro assert.equal(typed.ok, true); assert.equal(typed.ok && 'text' in typed.data ? typed.data.text : undefined, 'hello'); + const opened = await router.dispatch({ + command: 'apps.open', + options: { + app: 'com.example.app', + relaunch: true, + }, + }); + assert.equal(opened.ok, true); + assert.equal( + opened.ok && 'kind' in opened.data && opened.data.kind === 'appOpened' + ? opened.data.relaunch + : false, + true, + ); + + const listed = await router.dispatch({ + command: 'apps.list', + options: { filter: 'user-installed' }, + }); + assert.equal(listed.ok, true); + assert.equal( + listed.ok && 'kind' in listed.data && listed.data.kind === 'appsList' + ? listed.data.apps.length + : 0, + 1, + ); + const planned = await router.dispatch({ command: 'alert', options: {}, diff --git a/src/backend.ts b/src/backend.ts index 784659e40..5945916f0 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -90,9 +90,61 @@ export type BackendFillOptions = { }; export type BackendOpenTarget = { + /** + * Generic app identifier accepted by the backend. Hosted adapters should + * prefer structured appId, bundleId, or packageName when available. + */ app?: string; + appId?: string; + bundleId?: string; + packageName?: string; + /** + * URL may be used by itself for a deep link or with an app identifier when + * the backend supports opening a URL in a specific app context. + */ url?: string; + /** + * Platform-specific activity override, primarily for Android app launches. + */ + activity?: string; +}; + +export type BackendOpenOptions = { + relaunch?: boolean; +}; + +export type BackendAppListFilter = 'all' | 'user-installed'; + +export type BackendAppInfo = { + id: string; + name?: string; + bundleId?: string; + packageName?: string; + activity?: string; +}; + +export type BackendAppState = { + appId?: string; + bundleId?: string; + packageName?: string; activity?: string; + state?: 'unknown' | 'notRunning' | 'running' | 'foreground' | 'background'; + details?: Record; +}; + +export type BackendPushInput = + | { + kind: 'json'; + payload: Record; + } + | { + kind: 'file'; + path: string; + }; + +export type BackendAppEvent = { + name: string; + payload?: Record; }; export type BackendInstallTarget = { @@ -170,8 +222,26 @@ export type AgentDeviceBackend = { key: string, options?: { modifiers?: string[] }, ): Promise; - openApp?(context: BackendCommandContext, target: BackendOpenTarget): Promise; + openApp?( + context: BackendCommandContext, + target: BackendOpenTarget, + options?: BackendOpenOptions, + ): Promise; closeApp?(context: BackendCommandContext, app?: string): Promise; + listApps?( + context: BackendCommandContext, + filter?: BackendAppListFilter, + ): Promise; + getAppState?(context: BackendCommandContext, app: string): Promise; + pushFile?( + context: BackendCommandContext, + input: BackendPushInput, + target: string, + ): Promise; + triggerAppEvent?( + context: BackendCommandContext, + event: BackendAppEvent, + ): Promise; installApp?( context: BackendCommandContext, target: BackendInstallTarget, diff --git a/src/commands/apps.ts b/src/commands/apps.ts new file mode 100644 index 000000000..70d7e4ee2 --- /dev/null +++ b/src/commands/apps.ts @@ -0,0 +1,388 @@ +import type { + BackendActionResult, + BackendAppInfo, + BackendAppListFilter, + BackendAppState, + BackendCommandContext, + BackendOpenTarget, + BackendPushInput, +} from '../backend.ts'; +import type { FileInputRef } from '../io.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import { AppError } from '../utils/errors.ts'; +import { successText } from '../utils/success-text.ts'; +import { resolveCommandInput } from './io-policy.ts'; +import type { RuntimeCommand } from './index.ts'; + +const APP_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/; +const MAX_APP_EVENT_PAYLOAD_BYTES = 8 * 1024; +const MAX_APP_PUSH_PAYLOAD_BYTES = 8 * 1024; + +export type OpenAppCommandOptions = CommandContext & + BackendOpenTarget & { + relaunch?: boolean; + }; + +export type OpenAppCommandResult = { + kind: 'appOpened'; + target: BackendOpenTarget; + relaunch: boolean; + backendResult?: Record; + message?: string; +}; + +export type CloseAppCommandOptions = CommandContext & { + app?: string; +}; + +export type CloseAppCommandResult = { + kind: 'appClosed'; + app?: string; + backendResult?: Record; + message?: string; +}; + +export type ListAppsCommandOptions = CommandContext & { + filter?: BackendAppListFilter; +}; + +export type ListAppsCommandResult = { + kind: 'appsList'; + apps: readonly BackendAppInfo[]; +}; + +export type GetAppStateCommandOptions = CommandContext & { + app: string; +}; + +export type GetAppStateCommandResult = { + kind: 'appState'; + app: string; + state: BackendAppState; +}; + +export type AppPushInput = + | { + kind: 'json'; + payload: Record; + } + | FileInputRef; + +export type PushAppCommandOptions = CommandContext & { + app: string; + input: AppPushInput; +}; + +export type PushAppCommandResult = { + kind: 'appPushed'; + app: string; + inputKind: 'json' | 'file'; + backendResult?: Record; + message?: string; +}; + +export type TriggerAppEventCommandOptions = CommandContext & { + name: string; + payload?: Record; +}; + +export type TriggerAppEventCommandResult = { + kind: 'appEventTriggered'; + name: string; + payload?: Record; + backendResult?: Record; + message?: string; +}; + +export const openAppCommand: RuntimeCommand = async ( + runtime, + options, +): Promise => { + if (!runtime.backend.openApp) { + throw new AppError('UNSUPPORTED_OPERATION', 'apps.open is not supported by this backend'); + } + + const target = normalizeOpenTarget(options); + const backendResult = await runtime.backend.openApp( + toAppBackendContext(runtime, options), + target, + { + relaunch: options.relaunch, + }, + ); + + const formattedBackendResult = toBackendResult(backendResult); + return { + kind: 'appOpened', + target, + relaunch: options.relaunch === true, + ...(formattedBackendResult ? { backendResult: formattedBackendResult } : {}), + ...successText(`Opened: ${formatOpenTarget(target)}`), + }; +}; + +export const closeAppCommand: RuntimeCommand< + CloseAppCommandOptions | undefined, + CloseAppCommandResult +> = async (runtime, options = {}): Promise => { + if (!runtime.backend.closeApp) { + throw new AppError('UNSUPPORTED_OPERATION', 'apps.close is not supported by this backend'); + } + + const app = normalizeOptionalText(options.app, 'app'); + const backendResult = await runtime.backend.closeApp(toAppBackendContext(runtime, options), app); + + const formattedBackendResult = toBackendResult(backendResult); + return { + kind: 'appClosed', + ...(app ? { app } : {}), + ...(formattedBackendResult ? { backendResult: formattedBackendResult } : {}), + ...successText(app ? `Closed: ${app}` : 'Closed app'), + }; +}; + +export const listAppsCommand: RuntimeCommand< + ListAppsCommandOptions | undefined, + ListAppsCommandResult +> = async (runtime, options = {}): Promise => { + if (!runtime.backend.listApps) { + throw new AppError('UNSUPPORTED_OPERATION', 'apps.list is not supported by this backend'); + } + + const apps = await runtime.backend.listApps( + toAppBackendContext(runtime, options), + options.filter ?? 'all', + ); + return { + kind: 'appsList', + apps, + }; +}; + +export const getAppStateCommand: RuntimeCommand< + GetAppStateCommandOptions, + GetAppStateCommandResult +> = async (runtime, options): Promise => { + if (!runtime.backend.getAppState) { + throw new AppError('UNSUPPORTED_OPERATION', 'apps.state is not supported by this backend'); + } + + const app = requireText(options.app, 'app'); + const state = await runtime.backend.getAppState(toAppBackendContext(runtime, options), app); + return { + kind: 'appState', + app, + state, + }; +}; + +export const pushAppCommand: RuntimeCommand = async ( + runtime, + options, +): Promise => { + if (!runtime.backend.pushFile) { + throw new AppError('UNSUPPORTED_OPERATION', 'apps.push is not supported by this backend'); + } + + const app = requireText(options.app, 'app'); + const input = await resolvePushInput(runtime, options.input); + try { + const backendResult = await runtime.backend.pushFile( + toAppBackendContext(runtime, options), + input.backendInput, + app, + ); + const formattedBackendResult = toBackendResult(backendResult); + return { + kind: 'appPushed', + app, + inputKind: input.inputKind, + ...(formattedBackendResult ? { backendResult: formattedBackendResult } : {}), + ...successText(`Pushed to ${app}`), + }; + } finally { + await input.cleanup?.(); + } +}; + +export const triggerAppEventCommand: RuntimeCommand< + TriggerAppEventCommandOptions, + TriggerAppEventCommandResult +> = async (runtime, options): Promise => { + if (!runtime.backend.triggerAppEvent) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'apps.triggerEvent is not supported by this backend', + ); + } + + const name = requireAppEventName(options.name); + assertPayload(options.payload, `apps.triggerEvent payload for "${name}"`); + const backendResult = await runtime.backend.triggerAppEvent( + toAppBackendContext(runtime, options), + { + name, + ...(options.payload ? { payload: options.payload } : {}), + }, + ); + + const formattedBackendResult = toBackendResult(backendResult); + return { + kind: 'appEventTriggered', + name, + ...(options.payload ? { payload: options.payload } : {}), + ...(formattedBackendResult ? { backendResult: formattedBackendResult } : {}), + ...successText(`Triggered app event: ${name}`), + }; +}; + +function normalizeOpenTarget(options: OpenAppCommandOptions): BackendOpenTarget { + const app = normalizeOptionalText(options.app, 'app'); + const appId = normalizeOptionalText(options.appId, 'appId'); + const bundleId = normalizeOptionalText(options.bundleId, 'bundleId'); + const packageName = normalizeOptionalText(options.packageName, 'packageName'); + const url = normalizeOptionalText(options.url, 'url'); + const activity = normalizeOptionalText(options.activity, 'activity'); + const target: BackendOpenTarget = { + ...(app ? { app } : {}), + ...(appId ? { appId } : {}), + ...(bundleId ? { bundleId } : {}), + ...(packageName ? { packageName } : {}), + ...(url ? { url } : {}), + ...(activity ? { activity } : {}), + }; + if (!hasOpenTarget(target)) { + throw new AppError( + 'INVALID_ARGS', + 'apps.open requires app, appId, bundleId, packageName, url, or activity', + ); + } + return target; +} + +function hasOpenTarget(target: BackendOpenTarget): boolean { + return Boolean( + target.app ?? + target.appId ?? + target.bundleId ?? + target.packageName ?? + target.url ?? + target.activity, + ); +} + +function formatOpenTarget(target: BackendOpenTarget): string { + return ( + target.app ?? + target.appId ?? + target.bundleId ?? + target.packageName ?? + target.url ?? + target.activity ?? + 'app' + ); +} + +function normalizeOptionalText(value: string | undefined, field: string): string | undefined { + if (value === undefined) return undefined; + return requireText(value, field); +} + +function requireText(value: string | undefined, field: string): string { + const text = value?.trim(); + if (!text) { + throw new AppError('INVALID_ARGS', `${field} must be a non-empty string`); + } + return text; +} + +async function resolvePushInput( + runtime: AgentDeviceRuntime, + input: AppPushInput | undefined, +): Promise<{ + backendInput: BackendPushInput; + inputKind: 'json' | 'file'; + cleanup?: () => Promise; +}> { + if (!input || typeof input !== 'object') { + throw new AppError('INVALID_ARGS', 'apps.push requires an input'); + } + if (input.kind === 'json') { + validateJsonObjectPayload(input.payload, 'apps.push JSON payload', MAX_APP_PUSH_PAYLOAD_BYTES); + return { + backendInput: { kind: 'json', payload: input.payload }, + inputKind: 'json', + }; + } + + const resolved = await resolveCommandInput(runtime, input, { + usage: 'apps.push', + field: 'input', + }); + return { + backendInput: { kind: 'file', path: resolved.path }, + inputKind: 'file', + ...(resolved.cleanup ? { cleanup: resolved.cleanup } : {}), + }; +} + +function requireAppEventName(name: string): string { + const normalized = requireText(name, 'name'); + if (!APP_EVENT_NAME_PATTERN.test(normalized)) { + throw new AppError('INVALID_ARGS', `Invalid apps.triggerEvent name: ${normalized}`, { + hint: 'Use 1-64 chars: letters, numbers, underscore, dot, colon, or dash.', + }); + } + return normalized; +} + +function assertPayload(payload: Record | undefined, field: string): void { + if (payload === undefined) return; + validateJsonObjectPayload(payload, field, MAX_APP_EVENT_PAYLOAD_BYTES); +} + +function validateJsonObjectPayload( + payload: Record, + field: string, + maxBytes: number, +): void { + assertJsonObject(payload, field); + const payloadBytes = Buffer.byteLength(stringifyJsonObject(payload, field), 'utf8'); + if (payloadBytes > maxBytes) { + throw new AppError('INVALID_ARGS', `${field} exceeds ${maxBytes} bytes`); + } +} + +function assertJsonObject(payload: Record, field: string): void { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new AppError('INVALID_ARGS', `${field} must be a JSON object`); + } +} + +function stringifyJsonObject(payload: Record, field: string): string { + try { + const serialized = JSON.stringify(payload); + if (typeof serialized !== 'string') { + throw new AppError('INVALID_ARGS', `${field} must be JSON-serializable`); + } + return serialized; + } catch { + throw new AppError('INVALID_ARGS', `${field} must be JSON-serializable`); + } +} + +function toAppBackendContext( + runtime: Pick, + options: CommandContext, +): BackendCommandContext { + return { + session: options.session, + requestId: options.requestId, + signal: options.signal ?? runtime.signal, + metadata: options.metadata, + }; +} + +function toBackendResult(result: BackendActionResult): Record | undefined { + return result && typeof result === 'object' ? result : undefined; +} diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 2b1230d77..a1ea402b6 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -1,4 +1,8 @@ export type CommandCatalogEntry = { + /** + * CLI command names track daemon compatibility migration. Namespaced entries + * track runtime router/API commands that are available independently. + */ command: string; category: | 'portable-runtime' @@ -32,6 +36,12 @@ export const commandCatalog: readonly CommandCatalogEntry[] = [ { command: 'close', category: 'portable-runtime', status: 'planned' }, { command: 'apps', category: 'portable-runtime', status: 'planned' }, { command: 'appstate', category: 'portable-runtime', status: 'planned' }, + { command: 'apps.open', category: 'portable-runtime', status: 'implemented' }, + { command: 'apps.close', category: 'portable-runtime', status: 'implemented' }, + { command: 'apps.list', category: 'portable-runtime', status: 'implemented' }, + { command: 'apps.state', category: 'portable-runtime', status: 'implemented' }, + { command: 'apps.push', category: 'portable-runtime', status: 'implemented' }, + { command: 'apps.triggerEvent', category: 'portable-runtime', status: 'implemented' }, { command: 'back', category: 'portable-runtime', status: 'planned' }, { command: 'home', category: 'portable-runtime', status: 'planned' }, { command: 'rotate', category: 'portable-runtime', status: 'planned' }, diff --git a/src/commands/index.ts b/src/commands/index.ts index 8a66aa7c6..02a2a297b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -51,6 +51,26 @@ import { type TypeTextCommandOptions, type TypeTextCommandResult, } from './interactions.ts'; +import { + closeAppCommand, + getAppStateCommand, + listAppsCommand, + openAppCommand, + pushAppCommand, + triggerAppEventCommand, + type CloseAppCommandOptions, + type CloseAppCommandResult, + type GetAppStateCommandOptions, + type GetAppStateCommandResult, + type ListAppsCommandOptions, + type ListAppsCommandResult, + type OpenAppCommandOptions, + type OpenAppCommandResult, + type PushAppCommandOptions, + type PushAppCommandResult, + type TriggerAppEventCommandOptions, + type TriggerAppEventCommandResult, +} from './apps.ts'; export type { ScreenshotCommandResult } from './capture-screenshot.ts'; export type { @@ -94,6 +114,21 @@ export type { TypeTextCommandOptions, TypeTextCommandResult, } from './interactions.ts'; +export type { + AppPushInput, + CloseAppCommandOptions, + CloseAppCommandResult, + GetAppStateCommandOptions, + GetAppStateCommandResult, + ListAppsCommandOptions, + ListAppsCommandResult, + OpenAppCommandOptions, + OpenAppCommandResult, + PushAppCommandOptions, + PushAppCommandResult, + TriggerAppEventCommandOptions, + TriggerAppEventCommandResult, +} from './apps.ts'; export { ref, selector } from './selector-read.ts'; export { commandCatalog } from './catalog.ts'; export type { CommandCatalogEntry } from './catalog.ts'; @@ -161,6 +196,14 @@ export type AgentDeviceCommands = { fill: RuntimeCommand; typeText: RuntimeCommand; }; + apps: { + open: RuntimeCommand; + close: RuntimeCommand; + list: RuntimeCommand; + state: RuntimeCommand; + push: RuntimeCommand; + triggerEvent: RuntimeCommand; + }; }; export type BoundAgentDeviceCommands = { @@ -215,6 +258,14 @@ export type BoundAgentDeviceCommands = { options?: Omit, ) => Promise; }; + apps: { + open: BoundRuntimeCommand; + close: (options?: CloseAppCommandOptions) => Promise; + list: (options?: ListAppsCommandOptions) => Promise; + state: BoundRuntimeCommand; + push: BoundRuntimeCommand; + triggerEvent: BoundRuntimeCommand; + }; }; export const commands: AgentDeviceCommands = { @@ -241,6 +292,14 @@ export const commands: AgentDeviceCommands = { fill: fillCommand, typeText: typeTextCommand, }, + apps: { + open: openAppCommand, + close: closeAppCommand, + list: listAppsCommand, + state: getAppStateCommand, + push: pushAppCommand, + triggerEvent: triggerAppEventCommand, + }, }; export function bindCommands(runtime: AgentDeviceRuntime): BoundAgentDeviceCommands { @@ -275,5 +334,13 @@ export function bindCommands(runtime: AgentDeviceRuntime): BoundAgentDeviceComma typeText: (text, options = {}) => commands.interactions.typeText(runtime, { ...options, text }), }, + apps: { + open: (options) => commands.apps.open(runtime, options), + close: (options) => commands.apps.close(runtime, options), + list: (options) => commands.apps.list(runtime, options), + state: (options) => commands.apps.state(runtime, options), + push: (options) => commands.apps.push(runtime, options), + triggerEvent: (options) => commands.apps.triggerEvent(runtime, options), + }, }; } diff --git a/src/commands/router.ts b/src/commands/router.ts index 1d6ffa1bc..a5992921b 100644 --- a/src/commands/router.ts +++ b/src/commands/router.ts @@ -39,6 +39,26 @@ import { type TypeTextCommandOptions, type TypeTextCommandResult, } from './interactions.ts'; +import { + closeAppCommand, + getAppStateCommand, + listAppsCommand, + openAppCommand, + pushAppCommand, + triggerAppEventCommand, + type CloseAppCommandOptions, + type CloseAppCommandResult, + type GetAppStateCommandOptions, + type GetAppStateCommandResult, + type ListAppsCommandOptions, + type ListAppsCommandResult, + type OpenAppCommandOptions, + type OpenAppCommandResult, + type PushAppCommandOptions, + type PushAppCommandResult, + type TriggerAppEventCommandOptions, + type TriggerAppEventCommandResult, +} from './apps.ts'; import type { DiffSnapshotCommandOptions, ScreenshotCommandOptions, @@ -106,6 +126,36 @@ export type CommandRouterRequest = command: 'interactions.typeText'; options: TypeTextCommandOptions; context?: TContext; + } + | { + command: 'apps.open'; + options: OpenAppCommandOptions; + context?: TContext; + } + | { + command: 'apps.close'; + options?: CloseAppCommandOptions; + context?: TContext; + } + | { + command: 'apps.list'; + options?: ListAppsCommandOptions; + context?: TContext; + } + | { + command: 'apps.state'; + options: GetAppStateCommandOptions; + context?: TContext; + } + | { + command: 'apps.push'; + options: PushAppCommandOptions; + context?: TContext; + } + | { + command: 'apps.triggerEvent'; + options: TriggerAppEventCommandOptions; + context?: TContext; }; export type CommandRouterResult = @@ -119,7 +169,13 @@ export type CommandRouterResult = | WaitCommandResult | PressCommandResult | FillCommandResult - | TypeTextCommandResult; + | TypeTextCommandResult + | OpenAppCommandResult + | CloseAppCommandResult + | ListAppsCommandResult + | GetAppStateCommandResult + | PushAppCommandResult + | TriggerAppEventCommandResult; export type CommandRouterResponse = | { @@ -176,6 +232,12 @@ const implementedRouterCommands = new Set([ 'interactions.press', 'interactions.fill', 'interactions.typeText', + 'apps.open', + 'apps.close', + 'apps.list', + 'apps.state', + 'apps.push', + 'apps.triggerEvent', ]); function assertRouterCommandImplemented(request: { command: string }): void { @@ -222,5 +284,17 @@ async function dispatchRuntimeCommand( return await fillCommand(runtime, request.options); case 'interactions.typeText': return await typeTextCommand(runtime, request.options); + case 'apps.open': + return await openAppCommand(runtime, request.options); + case 'apps.close': + return await closeAppCommand(runtime, request.options); + case 'apps.list': + return await listAppsCommand(runtime, request.options); + case 'apps.state': + return await getAppStateCommand(runtime, request.options); + case 'apps.push': + return await pushAppCommand(runtime, request.options); + case 'apps.triggerEvent': + return await triggerAppEventCommand(runtime, request.options); } } diff --git a/src/index.ts b/src/index.ts index e1594fed0..753f37cb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,10 @@ export type { AgentDeviceBackend, AgentDeviceBackendPlatform, BackendActionResult, + BackendAppEvent, + BackendAppInfo, + BackendAppListFilter, + BackendAppState, BackendCapabilityName, BackendCapabilitySet, BackendCommandContext, @@ -34,7 +38,9 @@ export type { BackendFillOptions, BackendInstallTarget, BackendFindTextResult, + BackendOpenOptions, BackendOpenTarget, + BackendPushInput, BackendReadTextResult, BackendRunnerCommand, BackendSnapshotAnalysis, @@ -65,6 +71,7 @@ export type { export type { BoundAgentDeviceCommands, BoundRuntimeCommand, + AppPushInput, CommandCatalogEntry, CommandResult, CommandRouter, @@ -72,8 +79,20 @@ export type { CommandRouterRequest, CommandRouterResponse, CommandRouterResult, + CloseAppCommandOptions, + CloseAppCommandResult, + GetAppStateCommandOptions, + GetAppStateCommandResult, + ListAppsCommandOptions, + ListAppsCommandResult, + OpenAppCommandOptions, + OpenAppCommandResult, + PushAppCommandOptions, + PushAppCommandResult, RuntimeCommand, SelectorSnapshotOptions, + TriggerAppEventCommandOptions, + TriggerAppEventCommandResult, TypeTextCommandOptions, TypeTextCommandResult, } from './commands/index.ts'; diff --git a/src/testing/conformance.ts b/src/testing/conformance.ts index 66ab587f3..0731da1b6 100644 --- a/src/testing/conformance.ts +++ b/src/testing/conformance.ts @@ -7,6 +7,9 @@ export type ConformanceRuntimeFactory = () => AgentDeviceRuntime | Promise; visibleSelector: string; visibleText: string; editableTarget: InteractionTarget; @@ -64,6 +67,9 @@ export type CommandConformanceSuite = { export const defaultCommandConformanceFixtures: CommandConformanceFixtures = { session: 'default', + app: 'com.example.app', + appEventName: 'example.ready', + appPushPayload: { aps: { alert: 'hello' } }, visibleSelector: 'label=Continue', visibleText: 'Continue', editableTarget: selector('label=Email'), @@ -204,10 +210,87 @@ export const interactionConformanceSuite = createCommandConformanceSuite({ ], }); +export const appsConformanceSuite = createCommandConformanceSuite({ + name: 'apps', + cases: [ + { + name: 'opens apps by id', + command: 'apps.open', + run: async (runtime, fixtures) => { + const result = await commands.apps.open(runtime, { + session: fixtures.session, + app: fixtures.app, + }); + assert.equal(result.kind, 'appOpened'); + assert.equal(result.target.app, fixtures.app); + }, + }, + { + name: 'closes apps by id', + command: 'apps.close', + run: async (runtime, fixtures) => { + const result = await commands.apps.close(runtime, { + session: fixtures.session, + app: fixtures.app, + }); + assert.equal(result.kind, 'appClosed'); + assert.equal(result.app, fixtures.app); + }, + }, + { + name: 'lists apps', + command: 'apps.list', + run: async (runtime) => { + const result = await commands.apps.list(runtime, { filter: 'all' }); + assert.equal(result.kind, 'appsList'); + assert.ok(Array.isArray(result.apps)); + }, + }, + { + name: 'reads app state', + command: 'apps.state', + run: async (runtime, fixtures) => { + const result = await commands.apps.state(runtime, { + session: fixtures.session, + app: fixtures.app, + }); + assert.equal(result.kind, 'appState'); + assert.equal(result.app, fixtures.app); + }, + }, + { + name: 'pushes app payloads', + command: 'apps.push', + run: async (runtime, fixtures) => { + const result = await commands.apps.push(runtime, { + session: fixtures.session, + app: fixtures.app, + input: { kind: 'json', payload: fixtures.appPushPayload }, + }); + assert.equal(result.kind, 'appPushed'); + assert.equal(result.inputKind, 'json'); + }, + }, + { + name: 'triggers app events', + command: 'apps.triggerEvent', + run: async (runtime, fixtures) => { + const result = await commands.apps.triggerEvent(runtime, { + session: fixtures.session, + name: fixtures.appEventName, + }); + assert.equal(result.kind, 'appEventTriggered'); + assert.equal(result.name, fixtures.appEventName); + }, + }, + ], +}); + export const commandConformanceSuites: readonly CommandConformanceSuite[] = [ captureConformanceSuite, selectorConformanceSuite, interactionConformanceSuite, + appsConformanceSuite, ]; export async function runCommandConformance( diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index 6d86884f9..cc12db357 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -19,6 +19,11 @@ "type": "file", "label": "Typed Client" }, + { + "name": "runtime-command-stability", + "type": "file", + "label": "Runtime Command Stability" + }, { "name": "commands", "type": "file", diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 657bae21f..5f911eb8a 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -17,7 +17,7 @@ Public subpath API exposed for Node consumers: - `agent-device/io` - artifact adapter types, file input refs, and file output refs - `agent-device/testing/conformance` - - conformance suites for backend/runtime parity across capture, selectors, and interactions + - conformance suites for backend/runtime parity across capture, selectors, interactions, and apps - `agent-device/metro` - `prepareRemoteMetro(options)` - `ensureMetroTunnel(options)` @@ -123,6 +123,7 @@ await device.capture.screenshot({ await device.selectors.waitForText('Ready', { session: 'default', timeoutMs: 5_000 }); await device.interactions.click(selector('label=Continue'), { session: 'default' }); +await device.apps.open({ session: 'default', app: 'com.example' }); ``` Implemented runtime namespaces are currently: @@ -130,14 +131,20 @@ Implemented runtime namespaces are currently: - `capture`: `screenshot`, `diffScreenshot`, `snapshot`, `diffSnapshot` - `selectors`: `find`, `get`, `getText`, `getAttrs`, `is`, `isVisible`, `isHidden`, `wait`, `waitForText` - `interactions`: `click`, `press`, `fill`, `typeText` +- `apps`: `open`, `close`, `list`, `state`, `push`, `triggerEvent` Commands that have not migrated are tracked in `commandCatalog` instead of being exposed as throwing methods. Backend authors can use `runCommandConformance()` or `assertCommandConformance()` from -`agent-device/testing/conformance` to verify capture, selector, and interaction +`agent-device/testing/conformance` to verify capture, selector, interaction, and app semantics against a prepared fixture app or test backend. +Use `createCommandRouter()` from `agent-device/commands` as the recommended +transport boundary for hosted adapters. The router applies command dispatch, +error normalization, and per-request runtime construction without exposing +daemon internals. + ## Command methods Use `client.command.()` for command-level device actions. It uses the same daemon transport path as the higher-level client methods, including session metadata, tenant/run/lease fields, normalized daemon errors, and remote artifact handling. diff --git a/website/docs/docs/runtime-command-stability.md b/website/docs/docs/runtime-command-stability.md new file mode 100644 index 000000000..2367fb390 --- /dev/null +++ b/website/docs/docs/runtime-command-stability.md @@ -0,0 +1,53 @@ +--- +title: Runtime Command Stability +--- + +# Runtime Command Stability + +The runtime command API is the stable command-semantics boundary for hosted +adapters, daemon compatibility shims, and direct Node integrations. + +## Versioning rules + +- New runtime commands are added under typed namespaces such as `capture`, + `selectors`, `interactions`, and `apps`. +- Public command methods are exposed only after their backend primitive, + JavaScript result shape, router dispatch, and conformance coverage are in + place. +- Result unions stay discriminated with a `kind` field. Additive fields are + allowed in minor releases; removing or changing existing fields requires a + major release or a documented migration window. +- Command options should keep `session`, `requestId`, `signal`, and `metadata` + from `CommandContext` so hosted transports can preserve cancellation and + audit scope. +- Backend primitives remain small named methods. Do not add generic + `run(command, args)` escape hatches for portable command behavior. +- File input, file output, named backend capabilities, and local path access + must stay policy-gated. +- Runtime JSON payloads must serialize to a JSON string and stay within the + command's byte limit before reaching backend adapters. + +## Deprecation rules + +- Planned commands belong in `commandCatalog` until they are implemented. +- Compatibility helper subpaths remain available during the migration, but new + command semantics should move behind `agent-device/commands`, + `agent-device/backend`, and `agent-device/io`. +- Helpers should not be hard-deprecated until downstream hosted adapters have + moved to runtime command APIs or `createCommandRouter()`. +- Deprecations must identify the replacement runtime namespace and the first + package version where the replacement is available. + +## Transport boundary + +Use `createCommandRouter()` for hosted or RPC transports. The router should be +the boundary that: + +- constructs a request-scoped runtime, +- applies per-command policy before dispatch, +- normalizes command errors, +- preserves per-request context, and +- avoids exposing daemon-only session or platform internals. + +Direct service integrations can call `createAgentDevice()` when they already +own backend, artifact, session, and policy objects in-process.