From 6fcae4af181028446b95e8f511290ce8fed7d9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 29 Jun 2026 17:47:38 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20typed=20command=20results,=20batch=202?= =?UTF-8?q?=20(home/back/rotate/app-switcher)=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire four more commands into the CommandResultMap spine, narrowing their public client return types from the open DaemonResponseData bag to closed shapes. Each shape is grounded in a re-read of the dispatch handler's literal return (src/core/dispatch.ts DISPATCH_HANDLERS) — a fixed `action` discriminant plus the always-present successText `message`: - home -> { action: 'home'; message } - back -> { action: 'back'; mode: BackMode; message } - rotate -> { action: 'rotate'; orientation: DeviceRotation; message } - app-switcher -> { action: 'app-switcher'; message } The handlers spread nothing else, so the shapes are closed (consistent with the viewport contract, the generic-dispatch Android dialog-recovery `warning` annotation is intentionally not part of the contract). The result types move from the client-types.ts mirror into a new src/contracts/navigation.ts; the now-unused CommandActionResult helper is deleted. Public export names are preserved (re-exported via client-types.ts -> index.ts), so no API break. The exact-equality parity test pins CommandResult === the contract type for all ten migrated commands; the public-root export test gains back/rotate samples. Verified: tsc --noEmit, oxfmt + oxlint --deny-warnings, fallow audit clean, Layering Guard empty, 791 tests across core/contracts/client/commands pass (the lone failure was the known-flaky daemon-client mid-stream-abort test, which passes in isolation). --- src/__tests__/contracts-schema-public.test.ts | 18 ++++++++ src/client-types.ts | 30 +++++--------- src/client.ts | 9 ++-- src/contracts/navigation.ts | 38 +++++++++++++++++ .../__tests__/command-result.test.ts | 41 +++++++++++++++---- src/core/command-descriptor/command-result.ts | 20 ++++++--- 6 files changed, 118 insertions(+), 38 deletions(-) create mode 100644 src/contracts/navigation.ts diff --git a/src/__tests__/contracts-schema-public.test.ts b/src/__tests__/contracts-schema-public.test.ts index 69d04a6f5..0220fe4ef 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 BackCommandResult, type BootCommandResult, type CommandResult, + type RotateCommandResult, type ShutdownCommandResult, type ViewportCommandResult, } from '../index.ts'; @@ -126,9 +128,25 @@ test('public root exports typed command result contracts', () => { } satisfies ViewportCommandResult; const viewportFromMap: CommandResult<'viewport'> = viewport; + const back = { + action: 'back', + mode: 'in-app', + message: 'Back', + } satisfies BackCommandResult; + const backFromMap: CommandResult<'back'> = back; + + const rotate = { + action: 'rotate', + orientation: 'portrait', + message: 'Rotated to portrait', + } satisfies RotateCommandResult; + const rotateFromMap: CommandResult<'rotate'> = rotate; + 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'); }); test('public daemon request schema accepts GitHub Actions artifact install sources', () => { diff --git a/src/client-types.ts b/src/client-types.ts index 051b66d9b..c9ea9c8af 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -53,6 +53,12 @@ export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from './alert export type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts'; export type { BootCommandResult, ShutdownCommandResult } from './contracts/device.ts'; export type { ViewportCommandResult } from './contracts/viewport.ts'; +export type { + AppSwitcherCommandResult, + BackCommandResult, + HomeCommandResult, + RotateCommandResult, +} from './contracts/navigation.ts'; export type AgentDeviceDaemonTransport = ( req: Omit, @@ -467,10 +473,6 @@ export type AlertCommandResult = DaemonResponseData & { items?: string[]; }; -type CommandActionResult = DaemonResponseData & { - action?: T; -}; - export type AppStateCommandResult = DaemonResponseData & { platform?: Platform; appName?: string; @@ -481,18 +483,6 @@ export type AppStateCommandResult = DaemonResponseData & { surface?: SessionSurface; }; -export type BackCommandResult = CommandActionResult<'back'> & { - mode?: BackMode; -}; - -export type HomeCommandResult = CommandActionResult<'home'>; - -export type RotateCommandResult = CommandActionResult<'rotate'> & { - orientation?: RotateCommandOptions['orientation']; -}; - -export type AppSwitcherCommandResult = CommandActionResult<'app-switcher'>; - export type KeyboardCommandResult = DaemonResponseData & { platform?: 'android' | 'ios'; action?: 'status' | 'dismiss' | 'enter'; @@ -536,10 +526,10 @@ export type AgentDeviceCommandClient = { wait: (options: WaitCommandOptions) => Promise; alert: (options?: AlertCommandOptions) => Promise; appState: (options?: AppStateCommandOptions) => Promise; - back: (options?: BackCommandOptions) => Promise; - home: (options?: HomeCommandOptions) => Promise; - rotate: (options: RotateCommandOptions) => Promise; - appSwitcher: (options?: AppSwitcherCommandOptions) => 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; reactNative: (options: ReactNativeCommandOptions) => Promise; diff --git a/src/client.ts b/src/client.ts index c06b53115..0a5ce26dd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -104,10 +104,11 @@ export function createAgentDeviceClient( wait: async (options) => await executeCommand('wait', options), alert: async (options = {}) => await executeCommand('alert', 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), + 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), reactNative: async (options) => await executeCommand('react-native', options), diff --git a/src/contracts/navigation.ts b/src/contracts/navigation.ts new file mode 100644 index 000000000..1be7f7e5b --- /dev/null +++ b/src/contracts/navigation.ts @@ -0,0 +1,38 @@ +import type { BackMode } from '../core/back-mode.ts'; +import type { DeviceRotation } from '../core/device-rotation.ts'; + +/** + * Closed results of the navigation/global action commands. Each mirrors the + * dispatch handler's literal return EXACTLY (src/core/dispatch.ts + * `DISPATCH_HANDLERS`): a fixed `action` discriminant plus the always-present + * `successText` message (the handlers always pass a non-empty message, so it is + * required here). The handlers spread nothing else, so the shapes are closed — + * consistent with the `viewport` contract, the generic-dispatch Android + * dialog-recovery `warning` annotation is intentionally not part of the contract. + */ + +/** `home` — `{ action: 'home', message: 'Home' }`. */ +export type HomeCommandResult = { + action: 'home'; + message: string; +}; + +/** `back` — `{ action: 'back', mode, message: 'Back' }`; `mode` defaults to `'in-app'`. */ +export type BackCommandResult = { + action: 'back'; + mode: BackMode; + message: string; +}; + +/** `rotate` — `{ action: 'rotate', orientation, message: 'Rotated to ' }`. */ +export type RotateCommandResult = { + action: 'rotate'; + orientation: DeviceRotation; + message: string; +}; + +/** `app-switcher` — `{ action: 'app-switcher', message: 'Opened app switcher' }`. */ +export type AppSwitcherCommandResult = { + action: 'app-switcher'; + 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 8aab6cd79..fe4a30ded 100644 --- a/src/core/command-descriptor/__tests__/command-result.test.ts +++ b/src/core/command-descriptor/__tests__/command-result.test.ts @@ -6,6 +6,12 @@ import type { } from '../../../contracts/interaction.ts'; import type { BootCommandResult, ShutdownCommandResult } from '../../../contracts/device.ts'; import type { ViewportCommandResult } from '../../../contracts/viewport.ts'; +import type { + AppSwitcherCommandResult, + BackCommandResult, + HomeCommandResult, + RotateCommandResult, +} from '../../../contracts/navigation.ts'; import type { CommandResult, CommandResultMap } from '../command-result.ts'; /** @@ -24,14 +30,22 @@ test('seeded CommandResult entries resolve to their existing contract result typ const boot: Equal, BootCommandResult> = true; const shutdown: Equal, ShutdownCommandResult> = true; const viewport: Equal, ViewportCommandResult> = true; - expect([press, fill, longPress, boot, shutdown, viewport]).toEqual([ - true, - true, - true, - true, - true, - true, - ]); + const home: Equal, HomeCommandResult> = true; + const back: Equal, BackCommandResult> = true; + const rotate: Equal, RotateCommandResult> = true; + const appSwitcher: Equal, AppSwitcherCommandResult> = true; + expect([ + press, + fill, + longPress, + boot, + shutdown, + viewport, + home, + back, + rotate, + appSwitcher, + ]).toEqual([true, true, true, true, true, true, true, true, true, true]); }); test('unmigrated commands fall back to the untyped Record bag, keeping the union total', () => { @@ -44,7 +58,16 @@ test('unmigrated commands fall back to the untyped Record bag, keeping the union test('CommandResultMap is seeded only from already-existing contract result types', () => { const keys: Equal< keyof CommandResultMap, - 'press' | 'fill' | 'longpress' | 'boot' | 'shutdown' | 'viewport' + | 'press' + | 'fill' + | 'longpress' + | 'boot' + | 'shutdown' + | 'viewport' + | 'home' + | 'back' + | 'rotate' + | 'app-switcher' > = true; expect(keys).toBe(true); }); diff --git a/src/core/command-descriptor/command-result.ts b/src/core/command-descriptor/command-result.ts index ace5177be..42493cbae 100644 --- a/src/core/command-descriptor/command-result.ts +++ b/src/core/command-descriptor/command-result.ts @@ -5,6 +5,12 @@ import type { } from '../../contracts/interaction.ts'; import type { BootCommandResult, ShutdownCommandResult } from '../../contracts/device.ts'; import type { ViewportCommandResult } from '../../contracts/viewport.ts'; +import type { + AppSwitcherCommandResult, + BackCommandResult, + HomeCommandResult, + RotateCommandResult, +} from '../../contracts/navigation.ts'; /** * The additive typed-result spine (ADR-0008, Phase 1 step 6). @@ -16,11 +22,11 @@ import type { ViewportCommandResult } from '../../contracts/viewport.ts'; * visualization, perf, logs, …) are deliberately omitted rather than given an * invented shape. * - * Phase 2 batch 1 wires the first map entries into the public client return - * types: `boot` / `shutdown` (closed device-lifecycle results) and `viewport` - * (closed `{ width, height, message }`) join the seed interaction trio - * (`press` / `fill` / `longpress`). Each entry is grounded in a re-read of the - * handler's literal return; see the per-type docstrings for the file source. + * 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. */ export interface CommandResultMap { press: PressCommandResult; @@ -29,6 +35,10 @@ export interface CommandResultMap { boot: BootCommandResult; shutdown: ShutdownCommandResult; viewport: ViewportCommandResult; + home: HomeCommandResult; + back: BackCommandResult; + rotate: RotateCommandResult; + 'app-switcher': AppSwitcherCommandResult; } /**