diff --git a/src/__tests__/contracts-schema-public.test.ts b/src/__tests__/contracts-schema-public.test.ts index 0220fe4ef..bc7deddae 100644 --- a/src/__tests__/contracts-schema-public.test.ts +++ b/src/__tests__/contracts-schema-public.test.ts @@ -4,8 +4,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError, + type AppStateCommandResult, type BackCommandResult, type BootCommandResult, + type ClipboardCommandResult, type CommandResult, type RotateCommandResult, type ShutdownCommandResult, @@ -142,11 +144,30 @@ test('public root exports typed command result contracts', () => { } satisfies RotateCommandResult; const rotateFromMap: CommandResult<'rotate'> = rotate; + const clipboard = { + action: 'write', + textLength: 11, + message: 'Clipboard updated', + } satisfies ClipboardCommandResult; + const clipboardFromMap: CommandResult<'clipboard'> = clipboard; + + const appstate = { + platform: 'android', + package: 'com.example.demo', + activity: 'com.example.demo.MainActivity', + } satisfies AppStateCommandResult; + const appstateFromMap: CommandResult<'appstate'> = appstate; + assert.equal(bootFromMap.booted, true); assert.equal(shutdownFromMap.shutdown.success, true); assert.equal(viewportFromMap.width, 390); assert.equal(backFromMap.mode, 'in-app'); assert.equal(rotateFromMap.orientation, 'portrait'); + assert.equal(clipboardFromMap.action === 'write' ? clipboardFromMap.textLength : -1, 11); + assert.equal( + appstateFromMap.platform === 'android' ? appstateFromMap.package : '', + 'com.example.demo', + ); }); test('public daemon request schema accepts GitHub Actions artifact install sources', () => { diff --git a/src/client-types.ts b/src/client-types.ts index c9ea9c8af..9ba9378eb 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -59,6 +59,8 @@ export type { HomeCommandResult, RotateCommandResult, } from './contracts/navigation.ts'; +export type { ClipboardCommandResult } from './contracts/clipboard.ts'; +export type { AppStateCommandResult } from './contracts/app-state.ts'; export type AgentDeviceDaemonTransport = ( req: Omit, @@ -473,16 +475,6 @@ export type AlertCommandResult = DaemonResponseData & { items?: string[]; }; -export type AppStateCommandResult = DaemonResponseData & { - platform?: Platform; - appName?: string; - appBundleId?: string; - package?: string; - activity?: string; - source?: 'session'; - surface?: SessionSurface; -}; - export type KeyboardCommandResult = DaemonResponseData & { platform?: 'android' | 'ios'; action?: 'status' | 'dismiss' | 'enter'; @@ -498,16 +490,6 @@ export type KeyboardCommandResult = DaemonResponseData & { attempts?: number; }; -export type ClipboardCommandResult = - | (DaemonResponseData & { - action: 'read'; - text: string; - }) - | (DaemonResponseData & { - action: 'write'; - textLength: number; - }); - export type ReactNativeCommandOptions = DeviceCommandBaseOptions & { action: 'dismiss-overlay'; }; @@ -525,13 +507,13 @@ export type ViewportCommandOptions = DeviceCommandBaseOptions & { export type AgentDeviceCommandClient = { wait: (options: WaitCommandOptions) => Promise; alert: (options?: AlertCommandOptions) => Promise; - appState: (options?: AppStateCommandOptions) => Promise; + appState: (options?: AppStateCommandOptions) => Promise>; back: (options?: BackCommandOptions) => Promise>; home: (options?: HomeCommandOptions) => Promise>; rotate: (options: RotateCommandOptions) => Promise>; appSwitcher: (options?: AppSwitcherCommandOptions) => Promise>; keyboard: (options?: KeyboardCommandOptions) => Promise; - clipboard: (options: ClipboardCommandOptions) => Promise; + clipboard: (options: ClipboardCommandOptions) => Promise>; reactNative: (options: ReactNativeCommandOptions) => Promise; prepare: (options: PrepareCommandOptions) => Promise; viewport: (options: ViewportCommandOptions) => Promise>; diff --git a/src/client.ts b/src/client.ts index 0a5ce26dd..2ce7a72ff 100644 --- a/src/client.ts +++ b/src/client.ts @@ -103,14 +103,16 @@ export function createAgentDeviceClient( command: { wait: async (options) => await executeCommand('wait', options), alert: async (options = {}) => await executeCommand('alert', options), - appState: async (options = {}) => await executeCommand('appstate', options), + appState: async (options = {}) => + await executeCommand>('appstate', options), back: async (options = {}) => await executeCommand>('back', options), home: async (options = {}) => await executeCommand>('home', options), rotate: async (options) => await executeCommand>('rotate', options), appSwitcher: async (options = {}) => await executeCommand>('app-switcher', options), keyboard: async (options = {}) => await executeCommand('keyboard', options), - clipboard: async (options) => await executeCommand('clipboard', options), + clipboard: async (options) => + await executeCommand>('clipboard', options), reactNative: async (options) => await executeCommand('react-native', options), prepare: async (options) => await executeCommand('prepare', options), viewport: async (options) => diff --git a/src/contracts/app-state.ts b/src/contracts/app-state.ts new file mode 100644 index 000000000..013fe407d --- /dev/null +++ b/src/contracts/app-state.ts @@ -0,0 +1,30 @@ +import type { SessionSurface } from '../core/session-surface.ts'; + +/** + * Closed result of the `appstate` command, grounded in the daemon handler's + * success returns (src/daemon/handlers/session-state.ts `handleAppStateCommand`). + * A discriminated union on `platform`: + * - Apple (`ios` / `macos`) session state, with iOS-only device locators that + * the previous hand-written mirror omitted; and + * - Android foreground `package` / `activity`. + * + * The handler returns one of these fixed objects (errors take the `ok: false` + * path), so each branch is closed. + */ +export type AppStateCommandResult = + | { + platform: 'ios' | 'macos'; + appName: string; + appBundleId?: string; + source: 'session'; + surface: SessionSurface; + /** iOS only — the session device's UDID. */ + device_udid?: string; + /** iOS only — the simulator set path, or `null` when unknown. */ + ios_simulator_device_set?: string | null; + } + | { + platform: 'android'; + package: string; + activity: string; + }; diff --git a/src/contracts/clipboard.ts b/src/contracts/clipboard.ts new file mode 100644 index 000000000..2592333c6 --- /dev/null +++ b/src/contracts/clipboard.ts @@ -0,0 +1,17 @@ +/** + * Closed result of the `clipboard` command. Mirrors the dispatch handler's + * literal return EXACTLY (src/core/dispatch.ts `handleClipboardCommand`): a + * discriminated union on `action`. `read` returns the clipboard `text`; `write` + * reports the written `textLength` plus the `successText` message. The handler + * spreads nothing else, so each branch is closed. + */ +export type ClipboardCommandResult = + | { + action: 'read'; + text: string; + } + | { + action: 'write'; + textLength: number; + message: string; + }; diff --git a/src/core/command-descriptor/__tests__/command-result.test.ts b/src/core/command-descriptor/__tests__/command-result.test.ts index fe4a30ded..3e3b28300 100644 --- a/src/core/command-descriptor/__tests__/command-result.test.ts +++ b/src/core/command-descriptor/__tests__/command-result.test.ts @@ -12,6 +12,8 @@ import type { HomeCommandResult, RotateCommandResult, } from '../../../contracts/navigation.ts'; +import type { ClipboardCommandResult } from '../../../contracts/clipboard.ts'; +import type { AppStateCommandResult } from '../../../contracts/app-state.ts'; import type { CommandResult, CommandResultMap } from '../command-result.ts'; /** @@ -34,6 +36,8 @@ test('seeded CommandResult entries resolve to their existing contract result typ const back: Equal, BackCommandResult> = true; const rotate: Equal, RotateCommandResult> = true; const appSwitcher: Equal, AppSwitcherCommandResult> = true; + const clipboard: Equal, ClipboardCommandResult> = true; + const appstate: Equal, AppStateCommandResult> = true; expect([ press, fill, @@ -45,7 +49,9 @@ test('seeded CommandResult entries resolve to their existing contract result typ back, rotate, appSwitcher, - ]).toEqual([true, true, true, true, true, true, true, true, true, true]); + clipboard, + appstate, + ]).toEqual([true, true, true, true, true, true, true, true, true, true, true, true]); }); test('unmigrated commands fall back to the untyped Record bag, keeping the union total', () => { @@ -68,6 +74,8 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'back' | 'rotate' | 'app-switcher' + | 'clipboard' + | 'appstate' > = true; expect(keys).toBe(true); }); diff --git a/src/core/command-descriptor/command-result.ts b/src/core/command-descriptor/command-result.ts index 42493cbae..29321decc 100644 --- a/src/core/command-descriptor/command-result.ts +++ b/src/core/command-descriptor/command-result.ts @@ -11,6 +11,8 @@ import type { HomeCommandResult, RotateCommandResult, } from '../../contracts/navigation.ts'; +import type { ClipboardCommandResult } from '../../contracts/clipboard.ts'; +import type { AppStateCommandResult } from '../../contracts/app-state.ts'; /** * The additive typed-result spine (ADR-0008, Phase 1 step 6). @@ -22,11 +24,12 @@ import type { * visualization, perf, logs, …) are deliberately omitted rather than given an * invented shape. * - * Batch 1 wired `boot` / `shutdown` / `viewport` alongside the seed interaction - * trio (`press` / `fill` / `longpress`). Batch 2 adds the closed navigation/ - * action commands `home` / `back` / `rotate` / `app-switcher` (each a fixed - * `{ action, …, message }`). Each entry is grounded in a re-read of the handler's - * literal return; see the per-type docstrings for the file source. + * Batches 1-2 wired `boot` / `shutdown` / `viewport` and the navigation/action + * commands `home` / `back` / `rotate` / `app-switcher` alongside the seed + * interaction trio. Batch 3 adds `clipboard` (a closed `read`/`write` union) and + * `appstate` (a closed `platform` union — Apple session state with the iOS-only + * device locators, or Android package/activity). Each entry is grounded in a + * re-read of the handler's literal return; see the per-type docstrings. */ export interface CommandResultMap { press: PressCommandResult; @@ -39,6 +42,8 @@ export interface CommandResultMap { back: BackCommandResult; rotate: RotateCommandResult; 'app-switcher': AppSwitcherCommandResult; + clipboard: ClipboardCommandResult; + appstate: AppStateCommandResult; } /** diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 08349bfab..e2b775ad2 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -533,6 +533,7 @@ async function runAndroidSetupAndInstallWorkflow( assert.equal(braceFilePush.json.result.data.extrasCount, 1); const clipboard = await client.command.clipboard({ action: 'read', ...selection }); + if (clipboard.action !== 'read') throw new Error('expected clipboard read result'); assert.equal(clipboard.text, 'hello'); const clipboardWrite = await client.command.clipboard({ @@ -540,12 +541,14 @@ async function runAndroidSetupAndInstallWorkflow( text: 'android otp', ...selection, }); + if (clipboardWrite.action !== 'write') throw new Error('expected clipboard write result'); assert.equal(clipboardWrite.textLength, 11); const clipboardAfterWrite = await client.command.clipboard({ action: 'read', ...selection, }); + if (clipboardAfterWrite.action !== 'read') throw new Error('expected clipboard read result'); assert.equal(clipboardAfterWrite.text, 'android otp'); const keyboard = await client.command.keyboard({ action: 'status', ...selection });