From 438bb7e9a382ab71243665d09129f401ad16d85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 19:30:56 +0200 Subject: [PATCH 1/3] refactor: rename rotate command to orientation, keep rotate as a deprecated alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level `rotate` command (device orientation: portrait/landscape) shared a name with the `gesture rotate` two-finger rotation gesture. Rename the orientation command to `orientation` and keep `rotate` working as a minimal, silent CLI alias (same mechanism as `tap`->`press`) for a few versions. The rename is applied across every layer: - command-descriptor registry `name`, daemon dispatch handler, and the typed system facet (metadata/cliReader/daemonWriter/schema/output formatter) - navigation projection + `CommandResultMap` (`OrientationCommandResult`, `action: 'orientation'`), client types (`OrientationCommandOptions`), and the runtime family (`device.system.orientation`) - interactor + backend methods -> `setOrientation` (matching the backend's `setKeyboard`/`setClipboard` verb convention); Android helper `rotateAndroid` -> `setAndroidOrientation` - Apple/cloud-webdriver capability keys and plugin gate - user-facing docs (commands.md, client-api.md) Client SDK method is `orientation` (client convention = camelCase of the command name, matching `back`/`home`/`appSwitcher`); execution layers use the imperative `setOrientation`. Deliberately unchanged: - the Swift runner wire protocol keeps `command: 'rotate'` — the runner has its own command namespace with no gesture collision, so renaming it would only risk CLI<->installed-runner version skew on physical devices - the `DeviceRotation` value type / `parseDeviceRotation` (names the orientation values, no collision) Note: `client.command.rotate` / `device.system.rotate` and the `RotateCommand*` exported types are removed (the alias only rewrites CLI tokens); SDK consumers must use `orientation`. The JSON `action` value changes `rotate` -> `orientation`. --- src/__tests__/cli-help.test.ts | 9 ++++ src/__tests__/contracts-schema-public.test.ts | 12 ++--- src/__tests__/runtime-public.test.ts | 2 +- src/backend.ts | 2 +- src/cli-command-aliases.ts | 2 + .../__tests__/args-parse-interaction.test.ts | 10 +++- .../__tests__/cli-help-command-usage.test.ts | 12 +++-- .../parser/__tests__/cli-help-topics.test.ts | 2 +- .../__tests__/command-suggestions.test.ts | 6 +-- src/cli/parser/command-suggestions.ts | 5 +- src/client/client-types.ts | 5 +- src/cloud-webdriver/capabilities.ts | 4 +- src/cloud-webdriver/webdriver-interactor.ts | 5 +- src/commands/system/index.test.ts | 26 +++++----- src/commands/system/index.ts | 48 +++++++++---------- src/commands/system/navigation-projection.ts | 12 ++--- src/commands/system/output.ts | 2 +- src/commands/system/runtime/index.ts | 14 +++--- src/commands/system/runtime/system.test.ts | 10 ++-- src/commands/system/runtime/system.ts | 27 ++++++----- src/contracts/device-rotation.test.ts | 2 +- src/contracts/device-rotation.ts | 2 +- src/contracts/navigation.ts | 6 +-- src/core/__tests__/capabilities.test.ts | 12 ++--- .../capability-plugin-routing-parity.test.ts | 2 +- .../__tests__/dispatch-interactions.test.ts | 2 +- ...e.test.ts => dispatch-orientation.test.ts} | 15 +++--- .../__tests__/command-result.test.ts | 8 ++-- src/core/command-descriptor/command-result.ts | 6 +-- src/core/command-descriptor/registry.ts | 2 +- src/core/dispatch.ts | 6 +-- src/core/interactor-types.ts | 2 +- src/core/interactors/android.ts | 4 +- src/core/interactors/linux.ts | 4 +- src/core/interactors/web.ts | 2 +- .../apple-os-capability-table-parity.test.ts | 2 +- .../__tests__/daemon-command-registry.test.ts | 4 +- .../__tests__/request-handler-catalog.test.ts | 2 +- .../android/__tests__/input-actions.test.ts | 6 +-- src/platforms/android/input-actions.ts | 2 +- src/platforms/apple/capabilities.ts | 2 +- src/platforms/apple/interactor.ts | 4 +- src/platforms/apple/plugin.ts | 4 +- .../android-lifecycle.test.ts | 4 +- .../apple-platform-output-guard.test.ts | 2 +- website/docs/docs/client-api.md | 2 +- website/docs/docs/commands.md | 12 ++--- 47 files changed, 184 insertions(+), 152 deletions(-) rename src/core/__tests__/{dispatch-rotate.test.ts => dispatch-orientation.test.ts} (71%) diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index 3ce5bce72..d1388b99e 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -254,6 +254,15 @@ test('launch dispatches as a plain open without forcing a relaunch', async () => assert.notEqual(result.calls[0]?.flags?.relaunch, true); }); +test('rotate dispatches as orientation (deprecated alias) with positionals preserved', async () => { + const result = await runCliCapture(['rotate', 'landscape-left', '--json']); + assert.doesNotMatch(result.stderr, /Unknown command/); + // Canonicalization: the daemon call must record orientation, never rotate. + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'orientation'); + assert.deepEqual(result.calls[0]?.positionals, ['landscape-left']); +}); + // From #1052 (credit: @vku2018): the alias must compose with the bare-ref // hint — `tap e3` normalizes to press, then gets the @e3 suggestion. test('tap with a bare ref gets the @ref hint, not an unknown-command error', async () => { diff --git a/src/__tests__/contracts-schema-public.test.ts b/src/__tests__/contracts-schema-public.test.ts index 384d6f48c..19d9b4961 100644 --- a/src/__tests__/contracts-schema-public.test.ts +++ b/src/__tests__/contracts-schema-public.test.ts @@ -11,7 +11,7 @@ import type { AppSwitcherCommandResult, BackCommandResult, HomeCommandResult, - RotateCommandResult, + OrientationCommandResult, TvRemoteCommandResult, } from '../contracts/navigation.ts'; import type { ViewportCommandResult } from '../contracts/viewport.ts'; @@ -121,12 +121,12 @@ test('command result contracts are assignable to command result map', () => { } satisfies AppSwitcherCommandResult; const appSwitcherFromMap: CommandResult<'app-switcher'> = appSwitcher; - const rotate = { - action: 'rotate', + const orientation = { + action: 'orientation', orientation: 'portrait', message: 'Rotated to portrait', - } satisfies RotateCommandResult; - const rotateFromMap: CommandResult<'rotate'> = rotate; + } satisfies OrientationCommandResult; + const orientationFromMap: CommandResult<'orientation'> = orientation; const tvRemote = { action: 'tv-remote', @@ -155,7 +155,7 @@ test('command result contracts are assignable to command result map', () => { assert.equal(backFromMap.mode, 'in-app'); assert.equal(homeFromMap.action, 'home'); assert.equal(appSwitcherFromMap.action, 'app-switcher'); - assert.equal(rotateFromMap.orientation, 'portrait'); + assert.equal(orientationFromMap.orientation, 'portrait'); assert.equal(tvRemoteFromMap.button, 'select'); assert.equal(clipboardFromMap.action === 'write' ? clipboardFromMap.textLength : -1, 11); assert.equal( diff --git a/src/__tests__/runtime-public.test.ts b/src/__tests__/runtime-public.test.ts index 34a2b7a22..d11f870c5 100644 --- a/src/__tests__/runtime-public.test.ts +++ b/src/__tests__/runtime-public.test.ts @@ -258,7 +258,7 @@ test('internal backend, commands, and io modules are usable', () => { assert.equal(typeof commands.interactions.gesture, 'function'); assert.equal(typeof commands.system.back, 'function'); assert.equal(typeof commands.system.home, 'function'); - assert.equal(typeof commands.system.rotate, 'function'); + assert.equal(typeof commands.system.orientation, 'function'); assert.equal(typeof commands.system.keyboard, 'function'); assert.equal(typeof commands.system.clipboard, 'function'); assert.equal(typeof commands.system.settings, 'function'); diff --git a/src/backend.ts b/src/backend.ts index e6f5e7743..90ab06d81 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -493,7 +493,7 @@ export type AgentDeviceBackend = { context: BackendCommandContext, options: BackendTvRemoteOptions, ): Promise; - rotate?( + setOrientation?( context: BackendCommandContext, orientation: BackendDeviceOrientation, ): Promise; diff --git a/src/cli-command-aliases.ts b/src/cli-command-aliases.ts index 3719f51b9..552387ced 100644 --- a/src/cli-command-aliases.ts +++ b/src/cli-command-aliases.ts @@ -16,6 +16,8 @@ const CLI_COMMAND_ALIASES: readonly CliCommandAlias[] = [ { alias: 'tap', command: 'press' }, { alias: 'launch', command: 'open' }, { alias: 'relaunch', command: 'open', impliedFlags: ['relaunch'] }, + // Deprecated: `rotate` collided with the `gesture rotate` two-finger gesture. + { alias: 'rotate', command: 'orientation' }, ]; const aliasByToken: ReadonlyMap = new Map( diff --git a/src/cli/parser/__tests__/args-parse-interaction.test.ts b/src/cli/parser/__tests__/args-parse-interaction.test.ts index 483bb5e17..7aad7f835 100644 --- a/src/cli/parser/__tests__/args-parse-interaction.test.ts +++ b/src/cli/parser/__tests__/args-parse-interaction.test.ts @@ -305,9 +305,15 @@ test('parseArgs supports trigger-app-event payload argument', () => { assert.deepEqual(parsed.positionals, ['screenshot_taken', '{"source":"qa"}']); }); -test('parseArgs accepts rotate orientation aliases', () => { +test('parseArgs accepts the orientation command', () => { + const parsed = parseArgs(['orientation', 'left'], { strictFlags: true }); + assert.equal(parsed.command, 'orientation'); + assert.deepEqual(parsed.positionals, ['left']); +}); + +test('parseArgs normalizes the deprecated rotate alias to orientation', () => { const parsed = parseArgs(['rotate', 'left'], { strictFlags: true }); - assert.equal(parsed.command, 'rotate'); + assert.equal(parsed.command, 'orientation'); assert.deepEqual(parsed.positionals, ['left']); }); diff --git a/src/cli/parser/__tests__/cli-help-command-usage.test.ts b/src/cli/parser/__tests__/cli-help-command-usage.test.ts index d0bb47504..2dc1f8a28 100644 --- a/src/cli/parser/__tests__/cli-help-command-usage.test.ts +++ b/src/cli/parser/__tests__/cli-help-command-usage.test.ts @@ -372,11 +372,17 @@ test('keyboard command usage is documented', async () => { ); }); -test('rotate command usage is documented', async () => { +test('orientation command usage is documented', async () => { + const help = await usageForCommand('orientation'); + if (help === null) throw new Error('Expected command help text'); + assert.match(help, /orientation /); + assert.match(help, /Set device orientation on iOS and Android/); +}); + +test('deprecated rotate alias resolves to orientation usage', async () => { const help = await usageForCommand('rotate'); if (help === null) throw new Error('Expected command help text'); - assert.match(help, /rotate /); - assert.match(help, /Rotate device orientation on iOS and Android/); + assert.match(help, /orientation /); }); test('settings usage documents canonical faceid states', async () => { diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index 23194d1b0..de4a7011f 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -31,7 +31,7 @@ test('usage includes concise top-level commands', async () => { assert.doesNotMatch(usageText, /^ fling /m); assert.doesNotMatch(usageText, /^ pinch \[x\] \[y\]/m); assert.doesNotMatch(usageText, /^ rotate-gesture /m); - assert.match(usageText, /rotate /); + assert.match(usageText, /orientation /); assert.match(usageText, /record start \[path\] \| record stop/); assert.match(usageText, /trace start \| trace stop /); }); diff --git a/src/cli/parser/__tests__/command-suggestions.test.ts b/src/cli/parser/__tests__/command-suggestions.test.ts index 4847a5741..6432d57ce 100644 --- a/src/cli/parser/__tests__/command-suggestions.test.ts +++ b/src/cli/parser/__tests__/command-suggestions.test.ts @@ -43,12 +43,12 @@ test('the keyboard dismiss example uses a real keyboard action', () => { assert.doesNotThrow(() => keyboardCliReader(['dismiss'], baseFlags)); }); -// `launch`/`relaunch` (and `tap`) are true aliases normalized before the -// unknown-command check, so they must never appear in the suggestion map — +// `launch`/`relaunch`, `tap`, and `rotate` are true aliases normalized before +// the unknown-command check, so they must never appear in the suggestion map — // a stale entry there would be dead code masking the alias. test('true aliases are not listed in the curated suggestion map', () => { const guesses = new Set(listCommandAliasSuggestionEntries().map(([guess]) => guess)); - for (const alias of ['launch', 'relaunch', 'tap']) { + for (const alias of ['launch', 'relaunch', 'tap', 'rotate']) { assert.ok(!guesses.has(alias), `"${alias}" is a true alias and must not be a suggestion`); } }); diff --git a/src/cli/parser/command-suggestions.ts b/src/cli/parser/command-suggestions.ts index a67dd40f3..132dd7900 100644 --- a/src/cli/parser/command-suggestions.ts +++ b/src/cli/parser/command-suggestions.ts @@ -10,8 +10,9 @@ import { listCliCommandNames } from '../../command-catalog.ts'; * parse as a valid invocation of it; the registry-drift tests in * `src/cli/parser/__tests__/command-suggestions.test.ts` fail the build on drift. * - * True aliases (`tap` -> press, `launch`/`relaunch` -> open) are normalized - * case-insensitively in `normalizeCommandAlias` (args.ts) before the + * True aliases (`tap` -> press, `launch`/`relaunch` -> open, `rotate` -> + * orientation) are normalized case-insensitively in `normalizeCommandAlias` + * (args.ts) before the * unknown-command check runs, so they never reach this map and must not be * listed here. `start`/`restart` stay suggestion-only: `start` is genuinely * ambiguous, so a hint beats silently guessing. diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 803b88162..7b0f24154 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -75,7 +75,7 @@ export type { AppSwitcherCommandResult, BackCommandResult, HomeCommandResult, - RotateCommandResult, + OrientationCommandResult, TvRemoteCommandResult, } from '../contracts/navigation.ts'; export type { ClipboardCommandResult } from '../contracts/clipboard.ts'; @@ -539,7 +539,8 @@ export type AppStateCommandOptions = DeviceCommandBaseOptions; export type BackCommandOptions = DeviceCommandBaseOptions & NavigationCommandOptions<'back'>; -export type RotateCommandOptions = DeviceCommandBaseOptions & NavigationCommandOptions<'rotate'>; +export type OrientationCommandOptions = DeviceCommandBaseOptions & + NavigationCommandOptions<'orientation'>; export type AppSwitcherCommandOptions = DeviceCommandBaseOptions & NavigationCommandOptions<'app-switcher'>; diff --git a/src/cloud-webdriver/capabilities.ts b/src/cloud-webdriver/capabilities.ts index 356790876..8cb4ba962 100644 --- a/src/cloud-webdriver/capabilities.ts +++ b/src/cloud-webdriver/capabilities.ts @@ -18,7 +18,7 @@ export type CloudWebDriverOperation = | 'type' | 'back' | 'home' - | 'rotate' + | 'orientation' | 'appSwitcher' | 'tvRemote' | 'clipboard.read' @@ -92,7 +92,7 @@ const BASE_WEBDRIVER_CAPABILITIES: CloudWebDriverCapabilityMap = { support: 'partial', note: 'Uses provider/Appium mobile pressButton support where available.', }, - rotate: { + orientation: { support: 'partial', note: 'Uses provider/Appium mobile rotate support where available.', }, diff --git a/src/cloud-webdriver/webdriver-interactor.ts b/src/cloud-webdriver/webdriver-interactor.ts index a7b4242f4..85657ac4b 100644 --- a/src/cloud-webdriver/webdriver-interactor.ts +++ b/src/cloud-webdriver/webdriver-interactor.ts @@ -221,8 +221,9 @@ class WebDriverInteractor implements Interactor { await this.client.executeScript('mobile: pressButton', [{ name: 'home' }]); } - async rotate(orientation: DeviceRotation): Promise { - this.requireSupport('rotate'); + async setOrientation(orientation: DeviceRotation): Promise { + this.requireSupport('orientation'); + // `mobile: rotate` is Appium's device-orientation script command. await this.client.executeScript('mobile: rotate', [{ orientation }]); } diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index 835ee5c2f..f4dfc3248 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -3,7 +3,7 @@ import type { AgentDeviceCommandClient, AppSwitcherCommandOptions, BackCommandOptions, - RotateCommandOptions, + OrientationCommandOptions, TvRemoteCommandOptions, } from '../../client/client-types.ts'; import type { CommandResult } from '../../core/command-descriptor/command-result.ts'; @@ -21,8 +21,8 @@ import { homeDaemonWriter, keyboardCliReader, keyboardDaemonWriter, - rotateCliReader, - rotateDaemonWriter, + orientationCliReader, + orientationDaemonWriter, tvRemoteCliReader, tvRemoteDaemonWriter, systemCommandFamily, @@ -46,8 +46,8 @@ describe('system command interface', () => { expectTypeOf().toEqualTypeOf< (options?: BackCommandOptions) => Promise> >(); - expectTypeOf().toEqualTypeOf< - (options: RotateCommandOptions) => Promise> + expectTypeOf().toEqualTypeOf< + (options: OrientationCommandOptions) => Promise> >(); expectTypeOf().toEqualTypeOf< (options?: AppSwitcherCommandOptions) => Promise> @@ -62,7 +62,7 @@ describe('system command interface', () => { appState: 'appstate', back: 'back', home: 'home', - rotate: 'rotate', + orientation: 'orientation', appSwitcher: 'app-switcher', keyboard: 'keyboard', clipboard: 'clipboard', @@ -80,7 +80,7 @@ describe('system command interface', () => { ).toEqual({ back: 'back', home: 'home', - rotate: 'rotate', + orientation: 'orientation', 'app-switcher': 'appSwitcher', 'tv-remote': 'tvRemote', }); @@ -118,16 +118,16 @@ describe('system command interface', () => { ).toBeUndefined(); }); - test('rotate reader and writer normalize orientation', () => { - expect(rotateCliReader(['left'], flags())).toMatchObject({ + test('orientation reader and writer normalize orientation', () => { + expect(orientationCliReader(['left'], flags())).toMatchObject({ orientation: 'landscape-left', }); - expect(rotateDaemonWriter({ orientation: 'portrait' }).positionals).toEqual(['portrait']); + expect(orientationDaemonWriter({ orientation: 'portrait' }).positionals).toEqual(['portrait']); }); - test('rotate reader and writer reject missing orientation', () => { - expectInvalidArgs(() => rotateCliReader([], flags()), 'rotate requires an orientation'); - expectInvalidArgs(() => rotateDaemonWriter({}), 'rotate requires orientation'); + test('orientation reader and writer reject missing orientation', () => { + expectInvalidArgs(() => orientationCliReader([], flags()), 'orientation requires an orientation'); + expectInvalidArgs(() => orientationDaemonWriter({}), 'orientation requires orientation'); }); test('keyboard reader maps aliases and validates arguments', () => { diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 62314e8f5..a48145a6a 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -38,7 +38,7 @@ import { systemCliOutputFormatters } from './output.ts'; const APPSTATE_COMMAND_NAME = 'appstate'; const BACK_COMMAND_NAME = 'back'; const HOME_COMMAND_NAME = 'home'; -const ROTATE_COMMAND_NAME = 'rotate'; +const ORIENTATION_COMMAND_NAME = 'orientation'; const APP_SWITCHER_COMMAND_NAME = 'app-switcher'; const KEYBOARD_COMMAND_NAME = 'keyboard'; const CLIPBOARD_COMMAND_NAME = 'clipboard'; @@ -51,7 +51,7 @@ const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss'] as const; const appStateCommandDescription = 'Show foreground app or activity.'; const backCommandDescription = 'Navigate back.'; const homeCommandDescription = 'Go to the home screen.'; -const rotateCommandDescription = 'Rotate device orientation.'; +const orientationCommandDescription = 'Set device orientation.'; const appSwitcherCommandDescription = 'Open the app switcher.'; const keyboardCommandDescription = 'Inspect or dismiss the keyboard.'; const clipboardCommandDescription = 'Read or write clipboard text.'; @@ -73,9 +73,9 @@ const homeCommandMetadata = defineFieldCommandMetadata( {}, ); -const rotateCommandMetadata = defineFieldCommandMetadata( - ROTATE_COMMAND_NAME, - rotateCommandDescription, +const orientationCommandMetadata = defineFieldCommandMetadata( + ORIENTATION_COMMAND_NAME, + orientationCommandDescription, { orientation: requiredField(enumField(DEVICE_ROTATIONS)), }, @@ -135,10 +135,10 @@ const homeCommandDefinition = defineExecutableCommand( NAVIGATION_COMMAND_PROJECTIONS.home, ); -const rotateCommandDefinition = defineExecutableCommand( - rotateCommandMetadata, - (client, input) => client.command.rotate(input), - NAVIGATION_COMMAND_PROJECTIONS.rotate, +const orientationCommandDefinition = defineExecutableCommand( + orientationCommandMetadata, + (client, input) => client.command.orientation(input), + NAVIGATION_COMMAND_PROJECTIONS.orientation, ); const appSwitcherCommandDefinition = defineExecutableCommand( @@ -172,9 +172,9 @@ const backCliSchema = { allowedFlags: ['backMode'], } as const satisfies CommandSchemaOverride; -const rotateCliSchema = { - usageOverride: 'rotate ', - helpDescription: 'Rotate device orientation on iOS and Android', +const orientationCliSchema = { + usageOverride: 'orientation ', + helpDescription: 'Set device orientation on iOS and Android', positionalArgs: ['orientation'], } as const satisfies CommandSchemaOverride; @@ -213,7 +213,7 @@ export const backCliReader: CliReader = (_positionals, flags) => ({ mode: flags.backMode, }); -export const rotateCliReader: CliReader = (positionals, flags) => ({ +export const orientationCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), orientation: parseDeviceRotation(positionals[0]), }); @@ -240,8 +240,8 @@ export const backDaemonWriter: DaemonWriter = (input) => export const homeDaemonWriter: DaemonWriter = direct(HOME_COMMAND_NAME); -export const rotateDaemonWriter: DaemonWriter = direct(ROTATE_COMMAND_NAME, (input) => [ - requiredDaemonString(input.orientation, 'rotate requires orientation'), +export const orientationDaemonWriter: DaemonWriter = direct(ORIENTATION_COMMAND_NAME, (input) => [ + requiredDaemonString(input.orientation, 'orientation requires orientation'), ]); export const appSwitcherDaemonWriter: DaemonWriter = direct(APP_SWITCHER_COMMAND_NAME); @@ -288,14 +288,14 @@ const homeCommandFacet = defineCommandFacet({ cliOutputFormatter: systemCliOutputFormatters.home, }); -const rotateCommandFacet = defineCommandFacet({ - name: ROTATE_COMMAND_NAME, - metadata: rotateCommandMetadata, - definition: rotateCommandDefinition, - cliSchema: rotateCliSchema, - cliReader: rotateCliReader, - daemonWriter: rotateDaemonWriter, - cliOutputFormatter: systemCliOutputFormatters.rotate, +const orientationCommandFacet = defineCommandFacet({ + name: ORIENTATION_COMMAND_NAME, + metadata: orientationCommandMetadata, + definition: orientationCommandDefinition, + cliSchema: orientationCliSchema, + cliReader: orientationCliReader, + daemonWriter: orientationDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.orientation, }); const appSwitcherCommandFacet = defineCommandFacet({ @@ -345,7 +345,7 @@ export const systemCommandFamily = defineCommandFamilyFromFacets({ appStateCommandFacet, backCommandFacet, homeCommandFacet, - rotateCommandFacet, + orientationCommandFacet, appSwitcherCommandFacet, keyboardCommandFacet, clipboardCommandFacet, diff --git a/src/commands/system/navigation-projection.ts b/src/commands/system/navigation-projection.ts index 726a790d7..ade211431 100644 --- a/src/commands/system/navigation-projection.ts +++ b/src/commands/system/navigation-projection.ts @@ -4,7 +4,7 @@ import type { AppSwitcherCommandResult, BackCommandResult, HomeCommandResult, - RotateCommandResult, + OrientationCommandResult, TvRemoteCommandResult, } from '../../contracts/navigation.ts'; import { TV_REMOTE_BUTTONS, type TvRemoteButton } from '../../contracts/tv-remote.ts'; @@ -60,17 +60,17 @@ export const NAVIGATION_COMMAND_PROJECTIONS = { required: ['action', 'message'], }, }), - rotate: defineNavigationCommandProjection< + orientation: defineNavigationCommandProjection< { orientation: DeviceRotation }, - RotateCommandResult, + OrientationCommandResult, true, - 'rotate' + 'orientation' >({ - clientMethod: 'rotate', + clientMethod: 'orientation', outputSchema: { type: 'object', properties: { - action: { type: 'string', const: 'rotate' }, + action: { type: 'string', const: 'orientation' }, orientation: { type: 'string', enum: DEVICE_ROTATIONS }, message: { type: 'string' }, }, diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts index c4063bbc2..c770291d4 100644 --- a/src/commands/system/output.ts +++ b/src/commands/system/output.ts @@ -44,7 +44,7 @@ export const systemCliOutputFormatters = { appstate: resultOutput(appStateCliOutput), back: messageOutput, home: messageOutput, - rotate: messageOutput, + orientation: messageOutput, 'app-switcher': messageOutput, keyboard: resultOutput(keyboardCliOutput), clipboard: resultOutput(clipboardCliOutput), diff --git a/src/commands/system/runtime/index.ts b/src/commands/system/runtime/index.ts index 4eb3b3ed1..be808dc84 100644 --- a/src/commands/system/runtime/index.ts +++ b/src/commands/system/runtime/index.ts @@ -7,7 +7,7 @@ import { clipboardCommand, homeCommand, keyboardCommand, - rotateCommand, + orientationCommand, settingsCommand, tvRemoteCommand, type SystemAlertCommandOptions, @@ -22,8 +22,8 @@ import { type SystemHomeCommandResult, type SystemKeyboardCommandOptions, type SystemKeyboardCommandResult, - type SystemRotateCommandOptions, - type SystemRotateCommandResult, + type SystemOrientationCommandOptions, + type SystemOrientationCommandResult, type SystemSettingsCommandOptions, type SystemSettingsCommandResult, type SystemTvRemoteCommandOptions, @@ -33,7 +33,7 @@ import { export type SystemCommands = { back: RuntimeCommand; home: RuntimeCommand; - rotate: RuntimeCommand; + orientation: RuntimeCommand; keyboard: RuntimeCommand; clipboard: RuntimeCommand; settings: RuntimeCommand; @@ -48,7 +48,7 @@ export type SystemCommands = { export type BoundSystemCommands = { back: (options?: SystemBackCommandOptions) => Promise; home: (options?: SystemHomeCommandOptions) => Promise; - rotate: BoundRuntimeCommand; + orientation: BoundRuntimeCommand; keyboard: (options?: SystemKeyboardCommandOptions) => Promise; clipboard: BoundRuntimeCommand; settings: (options?: SystemSettingsCommandOptions) => Promise; @@ -62,7 +62,7 @@ export type BoundSystemCommands = { export const systemCommands: SystemCommands = { back: backCommand, home: homeCommand, - rotate: rotateCommand, + orientation: orientationCommand, keyboard: keyboardCommand, clipboard: clipboardCommand, settings: settingsCommand, @@ -75,7 +75,7 @@ export function bindSystemCommands(runtime: AgentDeviceRuntime): BoundSystemComm return { back: (options) => systemCommands.back(runtime, options), home: (options) => systemCommands.home(runtime, options), - rotate: (options) => systemCommands.rotate(runtime, options), + orientation: (options) => systemCommands.orientation(runtime, options), keyboard: (options) => systemCommands.keyboard(runtime, options), clipboard: (options) => systemCommands.clipboard(runtime, options), settings: (options) => systemCommands.settings(runtime, options), diff --git a/src/commands/system/runtime/system.test.ts b/src/commands/system/runtime/system.test.ts index 82c220e8f..0d917dadd 100644 --- a/src/commands/system/runtime/system.test.ts +++ b/src/commands/system/runtime/system.test.ts @@ -20,7 +20,7 @@ test('runtime system commands call typed backend primitives', async () => { const back = await device.system.back({ session: 'default', mode: 'system' }); const home = await device.system.home({ session: 'default' }); - const rotated = await device.system.rotate({ orientation: 'landscape-left' }); + const rotated = await device.system.orientation({ orientation: 'landscape-left' }); const keyboard = await device.system.keyboard({ action: 'dismiss' }); const clipboardRead = await device.system.clipboard({ action: 'read' }); const clipboardWrite = await device.system.clipboard({ action: 'write', text: 'hello' }); @@ -44,7 +44,7 @@ test('runtime system commands call typed backend primitives', async () => { assert.deepEqual(calls, [ { command: 'pressBack', mode: 'system', session: 'default' }, { command: 'pressHome', session: 'default' }, - { command: 'rotate', orientation: 'landscape-left' }, + { command: 'setOrientation', orientation: 'landscape-left' }, { command: 'setKeyboard', options: { action: 'dismiss' } }, { command: 'getClipboard' }, { command: 'setClipboard', text: 'hello' }, @@ -64,7 +64,7 @@ test('runtime system commands validate options before backend calls', async () = }); await assert.rejects( - () => device.system.rotate({ orientation: 'sideways' as BackendDeviceOrientation }), + () => device.system.orientation({ orientation: 'sideways' as BackendDeviceOrientation }), /orientation must be/, ); await assert.rejects( @@ -97,8 +97,8 @@ function createSystemBackend(calls: unknown[]): AgentDeviceBackend { pressHome: async (context) => { calls.push({ command: 'pressHome', session: context.session }); }, - rotate: async (_context, orientation) => { - calls.push({ command: 'rotate', orientation }); + setOrientation: async (_context, orientation) => { + calls.push({ command: 'setOrientation', orientation }); }, setKeyboard: async (_context, options) => { calls.push({ command: 'setKeyboard', options }); diff --git a/src/commands/system/runtime/system.ts b/src/commands/system/runtime/system.ts index c3082f448..43feba39e 100644 --- a/src/commands/system/runtime/system.ts +++ b/src/commands/system/runtime/system.ts @@ -37,12 +37,12 @@ export type SystemHomeCommandResult = { kind: 'systemHome'; } & BackendResultEnvelope; -export type SystemRotateCommandOptions = CommandContext & { +export type SystemOrientationCommandOptions = CommandContext & { orientation: BackendDeviceOrientation; }; -export type SystemRotateCommandResult = { - kind: 'systemRotated'; +export type SystemOrientationCommandResult = { + kind: 'systemOrientationSet'; orientation: BackendDeviceOrientation; } & BackendResultEnvelope; @@ -202,21 +202,24 @@ export const tvRemoteCommand: RuntimeCommand< }; }; -export const rotateCommand: RuntimeCommand< - SystemRotateCommandOptions, - SystemRotateCommandResult -> = async (runtime, options): Promise => { - if (!runtime.backend.rotate) { - throw new AppError('UNSUPPORTED_OPERATION', 'system.rotate is not supported by this backend'); +export const orientationCommand: RuntimeCommand< + SystemOrientationCommandOptions, + SystemOrientationCommandResult +> = async (runtime, options): Promise => { + if (!runtime.backend.setOrientation) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'system.orientation is not supported by this backend', + ); } const orientation = requireOrientation(options.orientation); - const backendResult = await runtime.backend.rotate( + const backendResult = await runtime.backend.setOrientation( toBackendContext(runtime, options), orientation, ); const formattedBackendResult = toBackendResult(backendResult); return { - kind: 'systemRotated', + kind: 'systemOrientationSet', orientation, ...(formattedBackendResult ? { backendResult: formattedBackendResult } : {}), ...successText(`Rotated to ${orientation}`), @@ -365,7 +368,7 @@ function requireOrientation(orientation: BackendDeviceOrientation): BackendDevic default: throw new AppError( 'INVALID_ARGS', - 'system.rotate orientation must be portrait, portrait-upside-down, landscape-left, or landscape-right', + 'system.orientation must be portrait, portrait-upside-down, landscape-left, or landscape-right', ); } } diff --git a/src/contracts/device-rotation.test.ts b/src/contracts/device-rotation.test.ts index 32a5416db..13fd600a3 100644 --- a/src/contracts/device-rotation.test.ts +++ b/src/contracts/device-rotation.test.ts @@ -23,7 +23,7 @@ describe('parseDeviceRotation', () => { expect(() => parseDeviceRotation(undefined)).toThrow( expect.objectContaining({ code: 'INVALID_ARGS', - message: expect.stringContaining('rotate requires an orientation argument'), + message: expect.stringContaining('orientation requires an orientation argument'), }), ); }); diff --git a/src/contracts/device-rotation.ts b/src/contracts/device-rotation.ts index 969ff57f1..bc9a9f76a 100644 --- a/src/contracts/device-rotation.ts +++ b/src/contracts/device-rotation.ts @@ -12,7 +12,7 @@ export function parseDeviceRotation(input: string | undefined): DeviceRotation { if (input === undefined) { throw new AppError( 'INVALID_ARGS', - 'rotate requires an orientation argument. Use portrait|portrait-upside-down|landscape-left|landscape-right.', + 'orientation requires an orientation argument. Use portrait|portrait-upside-down|landscape-left|landscape-right.', ); } const normalized = input?.trim().toLowerCase(); diff --git a/src/contracts/navigation.ts b/src/contracts/navigation.ts index f11be992b..2c6a37b42 100644 --- a/src/contracts/navigation.ts +++ b/src/contracts/navigation.ts @@ -25,9 +25,9 @@ export type BackCommandResult = { message: string; }; -/** `rotate` — `{ action: 'rotate', orientation, message: 'Rotated to ' }`. */ -export type RotateCommandResult = { - action: 'rotate'; +/** `orientation` — `{ action: 'orientation', orientation, message: 'Rotated to ' }`. */ +export type OrientationCommandResult = { + action: 'orientation'; orientation: DeviceRotation; message: string; }; diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 9852bbc38..3dddd21c7 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -190,7 +190,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => { 'perf', 'press', 'record', - 'rotate', + 'orientation', 'screenshot', 'scroll', 'snapshot', @@ -248,7 +248,7 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only 'install-from-source', 'push', 'reinstall', - 'rotate', + 'orientation', ], [{ device: macOsDevice, expected: false, label: 'on macOS' }], ); @@ -295,9 +295,9 @@ test('tvOS follows iOS capability matrix by device kind', () => { 'keyboard on tvOS simulator', ); assert.equal( - isCommandSupportedOnDevice('rotate', tvOsSimulator), + isCommandSupportedOnDevice('orientation', tvOsSimulator), false, - 'rotate on tvOS simulator', + 'orientation on tvOS simulator', ); }); @@ -342,7 +342,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'push', 'record', 'reinstall', - 'rotate', + 'orientation', 'settings', 'shutdown', 'trigger-app-event', @@ -393,7 +393,7 @@ test('web supports only the initial browser interaction slice', () => { 'perf', 'push', 'reinstall', - 'rotate', + 'orientation', 'settings', 'shutdown', 'swipe', diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index c7026934a..919c949d0 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -134,7 +134,7 @@ const SUPPORTS_REF: Record boolean> = { isMacOs(device) || device.kind === 'simulator', keyboard: supportsAndroidOrIosNonTv, - rotate: supportsAndroidOrIosNonTv, + orientation: supportsAndroidOrIosNonTv, 'tv-remote': supportsTvRemote, alert: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), settings: (device) => diff --git a/src/core/__tests__/dispatch-interactions.test.ts b/src/core/__tests__/dispatch-interactions.test.ts index e4b63e07c..4533986f6 100644 --- a/src/core/__tests__/dispatch-interactions.test.ts +++ b/src/core/__tests__/dispatch-interactions.test.ts @@ -48,7 +48,7 @@ function makeUnusedInteractor(): Interactor { snapshot: fail, back: fail, home: fail, - rotate: fail, + setOrientation: fail, appSwitcher: fail, tvRemote: fail, readClipboard: fail, diff --git a/src/core/__tests__/dispatch-rotate.test.ts b/src/core/__tests__/dispatch-orientation.test.ts similarity index 71% rename from src/core/__tests__/dispatch-rotate.test.ts rename to src/core/__tests__/dispatch-orientation.test.ts index f95003add..c5ed04a09 100644 --- a/src/core/__tests__/dispatch-rotate.test.ts +++ b/src/core/__tests__/dispatch-orientation.test.ts @@ -20,11 +20,11 @@ beforeEach(() => { mockRunAppleRunnerCommand.mockResolvedValue({ message: 'rotate', orientation: 'landscape-left' }); }); -test('dispatch rotate normalizes aliases before Android execution', async () => { - await withMockedAdb('agent-device-dispatch-rotate-android-', async (argsLogPath) => { - const result = await dispatchCommand(ANDROID_EMULATOR, 'rotate', ['left']); +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, 'rotate'); + assert.equal(result?.action, 'orientation'); assert.equal(result?.orientation, 'landscape-left'); const logged = await fs.readFile(argsLogPath, 'utf8'); @@ -33,15 +33,16 @@ test('dispatch rotate normalizes aliases before Android execution', async () => }); }); -test('dispatch rotate sends normalized orientation to the iOS runner', async () => { - const result = await dispatchCommand(IOS_DEVICE, 'rotate', ['right'], undefined, { +test('dispatch orientation sends normalized orientation to the iOS runner', async () => { + const result = await dispatchCommand(IOS_DEVICE, 'orientation', ['right'], undefined, { appBundleId: 'com.example.app', }); - assert.equal(result?.action, 'rotate'); + 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', diff --git a/src/core/command-descriptor/__tests__/command-result.test.ts b/src/core/command-descriptor/__tests__/command-result.test.ts index 3a88794af..93fa2f751 100644 --- a/src/core/command-descriptor/__tests__/command-result.test.ts +++ b/src/core/command-descriptor/__tests__/command-result.test.ts @@ -10,7 +10,7 @@ import type { AppSwitcherCommandResult, BackCommandResult, HomeCommandResult, - RotateCommandResult, + OrientationCommandResult, TvRemoteCommandResult, } from '../../../contracts/navigation.ts'; import type { ClipboardCommandResult } from '../../../contracts/clipboard.ts'; @@ -45,7 +45,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ const viewport: Equal, ViewportCommandResult> = true; const home: Equal, HomeCommandResult> = true; const back: Equal, BackCommandResult> = true; - const rotate: Equal, RotateCommandResult> = true; + const orientation: Equal, OrientationCommandResult> = true; const appSwitcher: Equal, AppSwitcherCommandResult> = true; const clipboard: Equal, ClipboardCommandResult> = true; const appstate: Equal, AppStateCommandResult> = true; @@ -74,7 +74,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ viewport, home, back, - rotate, + orientation, appSwitcher, clipboard, appstate, @@ -138,7 +138,7 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'viewport' | 'home' | 'back' - | 'rotate' + | 'orientation' | 'app-switcher' | 'clipboard' | 'appstate' diff --git a/src/core/command-descriptor/command-result.ts b/src/core/command-descriptor/command-result.ts index 93bd95efe..80117dc94 100644 --- a/src/core/command-descriptor/command-result.ts +++ b/src/core/command-descriptor/command-result.ts @@ -9,7 +9,7 @@ import type { AppSwitcherCommandResult, BackCommandResult, HomeCommandResult, - RotateCommandResult, + OrientationCommandResult, TvRemoteCommandResult, } from '../../contracts/navigation.ts'; import type { ClipboardCommandResult } from '../../contracts/clipboard.ts'; @@ -35,7 +35,7 @@ import type { ReplayCommandResult, ReplaySuiteResult } from '../../contracts/rep * invented shape. * * Batches 1-2 wired `boot` / `shutdown` / `viewport` and the navigation/action - * commands `home` / `back` / `rotate` / `app-switcher` alongside the seed + * commands `home` / `back` / `orientation` / `app-switcher` alongside the seed * interaction quartet. 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). Batch 4 adds `keyboard` (a @@ -53,7 +53,7 @@ export interface CommandResultMap { viewport: ViewportCommandResult; home: HomeCommandResult; back: BackCommandResult; - rotate: RotateCommandResult; + orientation: OrientationCommandResult; 'app-switcher': AppSwitcherCommandResult; clipboard: ClipboardCommandResult; appstate: AppStateCommandResult; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 9d5bc04c1..c9bff82a4 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -811,7 +811,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ batchable: true, }, { - name: 'rotate', + name: 'orientation', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 74fe439b7..3a66bd193 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -167,10 +167,10 @@ const DISPATCH_HANDLERS: Record = { await interactor.home(); return { action: 'home', ...successText('Home') }; }, - rotate: async ({ interactor, positionals }) => { + orientation: async ({ interactor, positionals }) => { const orientation = parseDeviceRotation(positionals[0]); - await interactor.rotate(orientation); - return { action: 'rotate', orientation, ...successText(`Rotated to ${orientation}`) }; + await interactor.setOrientation(orientation); + return { action: 'orientation', orientation, ...successText(`Rotated to ${orientation}`) }; }, 'app-switcher': async ({ interactor }) => { await interactor.appSwitcher(); diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 5f441ceb1..7fdd0e8c6 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -118,7 +118,7 @@ export type Interactor = { gestureViewport?(): Promise; back(mode?: BackMode): Promise; home(): Promise; - rotate(orientation: DeviceRotation): Promise; + setOrientation(orientation: DeviceRotation): Promise; performGesture?(plan: GesturePlan): Promise | void>; appSwitcher(): Promise; tvRemote(button: TvRemoteButton, durationMs?: number): Promise; diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 0a6ece3ac..543514885 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -12,8 +12,8 @@ import { longPressAndroid, pressAndroid, pressAndroidTvRemote, - rotateAndroid, scrollAndroid, + setAndroidOrientation, typeAndroid, } from '../../platforms/android/input-actions.ts'; import { @@ -81,7 +81,7 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor { }, back: (_mode) => backAndroid(device), home: () => homeAndroid(device), - rotate: (orientation) => rotateAndroid(device, orientation), + setOrientation: (orientation) => setAndroidOrientation(device, orientation), appSwitcher: () => appSwitcherAndroid(device), tvRemote: (button, durationMs) => pressAndroidTvRemote(device, button, durationMs), readClipboard: () => readAndroidClipboardText(device), diff --git a/src/core/interactors/linux.ts b/src/core/interactors/linux.ts index 198e72b4f..326e97f21 100644 --- a/src/core/interactors/linux.ts +++ b/src/core/interactors/linux.ts @@ -59,8 +59,8 @@ export function createLinuxInteractor(): Interactor { }, back: () => backLinux(), home: () => homeLinux(), - rotate: () => { - throw new AppError('UNSUPPORTED_OPERATION', 'rotate not supported on Linux'); + setOrientation: () => { + throw new AppError('UNSUPPORTED_OPERATION', 'orientation not supported on Linux'); }, appSwitcher: () => { throw new AppError('UNSUPPORTED_OPERATION', 'appSwitcher not yet supported on Linux'); diff --git a/src/core/interactors/web.ts b/src/core/interactors/web.ts index 36d9a31fd..393fa0f9e 100644 --- a/src/core/interactors/web.ts +++ b/src/core/interactors/web.ts @@ -32,7 +32,7 @@ export function createWebInteractor(): Interactor { }, back: () => unsupportedWebOperation('back'), home: () => unsupportedWebOperation('home'), - rotate: () => unsupportedWebOperation('rotate'), + setOrientation: () => unsupportedWebOperation('orientation'), appSwitcher: () => unsupportedWebOperation('appSwitcher'), tvRemote: () => unsupportedWebOperation('tvRemote'), readClipboard: () => unsupportedWebOperation('readClipboard'), diff --git a/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts b/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts index bd68354bf..f135ae98e 100644 --- a/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts +++ b/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts @@ -66,7 +66,7 @@ const SUPPORTS_REF: Record boolean> = { isMacOs(device) || device.kind === 'simulator', keyboard: supportsAndroidOrIosNonTv, - rotate: supportsAndroidOrIosNonTv, + orientation: supportsAndroidOrIosNonTv, 'tv-remote': supportsTvRemote, alert: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), settings: (device) => diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 5b69c986d..02fcb0a59 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -100,7 +100,7 @@ test('daemon command registry preserves replay and recording traits', () => { PUBLIC_COMMANDS.press, PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.reactNative, - PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.orientation, PUBLIC_COMMANDS.screenshot, PUBLIC_COMMANDS.scroll, PUBLIC_COMMANDS.settings, @@ -129,7 +129,7 @@ test('daemon command registry preserves Android modal and lock-policy traits', ( PUBLIC_COMMANDS.keyboard, PUBLIC_COMMANDS.longPress, PUBLIC_COMMANDS.press, - PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.orientation, PUBLIC_COMMANDS.scroll, PUBLIC_COMMANDS.swipe, PUBLIC_COMMANDS.type, diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 184513d1a..5f7b8cc2e 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -44,7 +44,7 @@ test('catalog commands use generic routing only when intentionally passthrough o PUBLIC_COMMANDS.focus, PUBLIC_COMMANDS.home, PUBLIC_COMMANDS.installFromSource, - PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.orientation, PUBLIC_COMMANDS.screenshot, PUBLIC_COMMANDS.scroll, PUBLIC_COMMANDS.tvRemote, diff --git a/src/platforms/android/__tests__/input-actions.test.ts b/src/platforms/android/__tests__/input-actions.test.ts index 8220f4e95..f52980894 100644 --- a/src/platforms/android/__tests__/input-actions.test.ts +++ b/src/platforms/android/__tests__/input-actions.test.ts @@ -4,8 +4,8 @@ import { promises as fs } from 'node:fs'; import { fillAndroid, longPressAndroid, - rotateAndroid, scrollAndroid, + setAndroidOrientation, typeAndroid, } from '../input-actions.ts'; import { AppError } from '../../../kernel/errors.ts'; @@ -115,12 +115,12 @@ test('longPressAndroid sends a stationary semantic touch plan', async () => { assert.equal(result.backend, 'provider-native-touch'); }); -test('rotateAndroid locks auto-rotate and sets user rotation', async () => { +test('setAndroidOrientation locks auto-rotate and sets user rotation', async () => { await withScriptedAdb( 'agent-device-android-rotate-landscape-left-', '#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', async ({ argsLogPath, device }) => { - await rotateAndroid(device, 'landscape-left'); + await setAndroidOrientation(device, 'landscape-left'); const lines = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); const logged = lines.join(' '); assert.match(logged, /shell settings put system accelerometer_rotation 0/); diff --git a/src/platforms/android/input-actions.ts b/src/platforms/android/input-actions.ts index f725e9fe3..3a4886ca9 100644 --- a/src/platforms/android/input-actions.ts +++ b/src/platforms/android/input-actions.ts @@ -54,7 +54,7 @@ export async function pressAndroidEnter(device: DeviceInfo): Promise { await runAndroidAdb(device, ['shell', 'input', 'keyevent', 'ENTER']); } -export async function rotateAndroid( +export async function setAndroidOrientation( device: DeviceInfo, orientation: DeviceRotation, ): Promise { diff --git a/src/platforms/apple/capabilities.ts b/src/platforms/apple/capabilities.ts index c1d79cfd5..9951c8fb4 100644 --- a/src/platforms/apple/capabilities.ts +++ b/src/platforms/apple/capabilities.ts @@ -32,7 +32,7 @@ export type AppleOsCapabilityProfile = { readonly appAndDeviceLifecycle: boolean; /** `keyboard` — hardware/text keyboard input. tvOS (focus-only) and macOS lack it. */ readonly keyboard: boolean; - /** `rotate` — device orientation. tvOS (focus-only) and macOS lack it. */ + /** `orientation` — device orientation. tvOS (focus-only) and macOS lack it. */ readonly orientation: boolean; /** * Whether `clipboard` / `alert` / `settings` are reachable on a PHYSICAL device of diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 5a6d2955a..d8d0f13f7 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -122,9 +122,11 @@ export function createAppleInteractor( runnerOpts, ); }, - rotate: async (orientation) => { + setOrientation: async (orientation) => { await runAppleRunnerCommand( device, + // `rotate` is the runner-protocol command name (its own namespace); the + // CLI-facing command/method is `orientation`. { command: 'rotate', orientation, appBundleId: runnerContext.appBundleId }, runnerOpts, ); diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 73cf7f26e..7ed34cbb6 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -38,7 +38,7 @@ const supportsKeyboard = (device: DeviceInfo): boolean => { return caps ? caps.keyboard : device.platform === 'android'; }; -// `rotate` (was `android || (ios && target !== 'tv')`). Off Apple: `android`. +// `orientation` (was `android || (ios && target !== 'tv')`). Off Apple: `android`. const supportsOrientation = (device: DeviceInfo): boolean => { const caps = appleOsCapabilities(device); return caps ? caps.orientation : device.platform === 'android'; @@ -78,7 +78,7 @@ const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> device.platform === 'linux' || supportsHostOrSimulatorSurface(device), [PUBLIC_COMMANDS.keyboard]: supportsKeyboard, - [PUBLIC_COMMANDS.rotate]: supportsOrientation, + [PUBLIC_COMMANDS.orientation]: supportsOrientation, [PUBLIC_COMMANDS.tvRemote]: supportsTvRemote, [PUBLIC_COMMANDS.alert]: (device) => device.platform === 'android' || supportsHostOrSimulatorSurface(device), diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 4b4a95e96..857b295d1 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -1023,11 +1023,11 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( assert.equal(diff.baselineInitialized, false); assert.deepEqual(diff.summary, { additions: 0, removals: 0, unchanged: 3 }); - const rotate = await client.command.rotate({ + const rotate = await client.command.orientation({ orientation: 'landscape-left', ...selection, }); - assert.equal(rotate.action, 'rotate'); + assert.equal(rotate.action, 'orientation'); assert.equal(rotate.orientation, 'landscape-left'); const appSwitcher = await client.command.appSwitcher(selection); diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index bf31c630b..0e720e4d3 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -119,7 +119,7 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.home]: () => one(), [PUBLIC_COMMANDS.focus]: () => one(['next']), [PUBLIC_COMMANDS.gesture]: () => one(['pinch', '0.8', '10', '10']), - [PUBLIC_COMMANDS.rotate]: () => one(['left']), + [PUBLIC_COMMANDS.orientation]: () => one(['left']), [PUBLIC_COMMANDS.scroll]: () => one(['down']), [PUBLIC_COMMANDS.swipe]: () => one(['up']), [PUBLIC_COMMANDS.tvRemote]: () => one(['select']), diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index b0394e1d2..3816700a9 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -249,7 +249,7 @@ Supported command methods: - `appState` - `back` - `home` -- `rotate` +- `orientation` - `appSwitcher` - `keyboard` - `clipboard` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index af8dc5bcd..87be5e547 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -49,8 +49,8 @@ agent-device back agent-device back --in-app agent-device back --system agent-device home -agent-device rotate portrait -agent-device rotate landscape-left +agent-device orientation portrait +agent-device orientation landscape-left agent-device app-switcher ``` @@ -74,8 +74,8 @@ agent-device app-switcher - `back` now defaults to app-owned back navigation. On Apple targets that means visible in-app back UI only. On Android this currently maps to the same back keyevent because Android routes in-app back through that platform event. - `back --in-app` is an explicit alias for the default app-owned behavior. - `back --system` asks for system back input explicitly. On Android this is the normal back keyevent. On iOS and tvOS it uses the platform back gesture or Siri Remote menu action. On macOS, where there is no generic system back input, `back --system` reports unavailable instead of falling back to app-owned navigation. -- `rotate ` forces a mobile device into `portrait`, `portrait-upside-down`, `landscape-left`, or `landscape-right`. -- `rotate` is supported on iOS and Android mobile targets. macOS and tvOS do not expose it. +- `orientation ` forces a mobile device into `portrait`, `portrait-upside-down`, `landscape-left`, or `landscape-right`. `rotate` is accepted as a deprecated alias. +- `orientation` is supported on iOS and Android mobile targets. macOS and tvOS do not expose it. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs require an active app in the session. - Commands that omit `--session` use an implicit `default` session scoped to the caller's current git worktree or working directory. This keeps independent local agents from accidentally attaching to each other's default session. - `--session ` or `AGENT_DEVICE_SESSION` opt into an explicitly named session when a script intentionally wants to share or reuse that session name. @@ -242,7 +242,7 @@ agent-device snapshot -i --platform apple --target desktop - In macOS app sessions, `screenshot` captures the target app window bounds rather than the full desktop. - Prefer selector or `@ref`-driven interactions on macOS. Window position can shift between runs, so raw x/y point commands are less stable than snapshot-derived targets. - Use `click --button secondary` for context menus on macOS, then run `snapshot -i` again. -- Mobile-only helpers remain unsupported on macOS: `boot`, `shutdown`, `home`, `rotate`, `app-switcher`, `install`, `reinstall`, `install-from-source`, and `push`. +- Mobile-only helpers remain unsupported on macOS: `boot`, `shutdown`, `home`, `orientation`, `app-switcher`, `install`, `reinstall`, `install-from-source`, and `push`. Recommended loops: @@ -389,7 +389,7 @@ done `longpress` is supported on iOS and Android. `gesture pinch` is supported on Android and iOS simulator app sessions. -`gesture rotate` is supported on Android and iOS simulator app sessions. Use `rotate` for device orientation. +`gesture rotate` is supported on Android and iOS simulator app sessions. Use `orientation` for device orientation. Two-finger `gesture pan` and `gesture transform` are supported on Android and iOS simulator app sessions. One-finger `gesture pan` keeps the broader platform support of ordinary coordinate drags. ## Find (semantic) From a4dd4ac1d270bae1c5aed9521fa3f486172a3adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 19:53:09 +0200 Subject: [PATCH 2/3] style: wrap long lines to satisfy oxfmt (orientation rename tests) --- .../parser/__tests__/cli-help-command-usage.test.ts | 10 ++++++++-- src/commands/system/index.test.ts | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/cli/parser/__tests__/cli-help-command-usage.test.ts b/src/cli/parser/__tests__/cli-help-command-usage.test.ts index 2dc1f8a28..298970bc4 100644 --- a/src/cli/parser/__tests__/cli-help-command-usage.test.ts +++ b/src/cli/parser/__tests__/cli-help-command-usage.test.ts @@ -375,14 +375,20 @@ test('keyboard command usage is documented', async () => { test('orientation command usage is documented', async () => { const help = await usageForCommand('orientation'); if (help === null) throw new Error('Expected command help text'); - assert.match(help, /orientation /); + assert.match( + help, + /orientation /, + ); assert.match(help, /Set device orientation on iOS and Android/); }); test('deprecated rotate alias resolves to orientation usage', async () => { const help = await usageForCommand('rotate'); if (help === null) throw new Error('Expected command help text'); - assert.match(help, /orientation /); + assert.match( + help, + /orientation /, + ); }); test('settings usage documents canonical faceid states', async () => { diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index f4dfc3248..6366fcbcc 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -126,7 +126,10 @@ describe('system command interface', () => { }); test('orientation reader and writer reject missing orientation', () => { - expectInvalidArgs(() => orientationCliReader([], flags()), 'orientation requires an orientation'); + expectInvalidArgs( + () => orientationCliReader([], flags()), + 'orientation requires an orientation', + ); expectInvalidArgs(() => orientationDaemonWriter({}), 'orientation requires orientation'); }); From 53d27edb9a39e6c1bacb39db80a892602c647d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 11:07:20 +0200 Subject: [PATCH 3/3] fix: add compatibility layer for the rotate->orientation rename Addresses review blockers on the CLI-only alias: `rotate` previously resolved only in CLI token parsing, so command-data/RPC paths that carry the wire command directly failed descriptor validation, and the removed typed SDK surface broke shipped consumers. Central command-alias boundary (was CLI-only): - Promote `cli-command-aliases.ts` to `command-aliases.ts` as the single alias source, applied at each command-name ingress that bypasses the CLI parser: the daemon request boundary (`handleRequest`, covering replay and older remote clients) and the batch step readers (CLI `batch-steps.ts` and daemon `batch-policy.ts`). No hand-synced command tables. Retain deprecated typed SDK surface (shipped v0.18/v0.19): - `RotateCommandOptions` / `RotateCommandResult` type aliases (legacy `action: 'rotate'` contract) and `SystemRotate*` runtime types. - `client.command.rotate` and `device.system.rotate` deprecated wrappers that delegate to `orientation` and restore the legacy response (`action: 'rotate'` / `kind: 'systemRotated'`). ADR 0014: rename `rotate` -> `orientation` in the invalidation guidance (lines 229, 237) so the accepted architecture doc matches the command name. Tests: daemon-boundary rewrite, CLI+daemon batch alias resolution, and the deprecated client/runtime wrappers preserving the legacy contract. Live emulator evidence (emulator-5554): - `orientation landscape-left` -> user_rotation=1 - `rotate portrait` (CLI alias) -> user_rotation=0 - batch step `{command:'rotate'}` (no CLI parser) -> user_rotation=1 --- docs/adr/0014-session-ref-frame-lifetime.md | 4 +- src/__tests__/batch-command-alias.test.ts | 35 +++++++ src/__tests__/client.test.ts | 20 ++++ src/__tests__/runtime-public.test.ts | 1 + src/agent-device-client.ts | 15 ++- src/batch-policy.ts | 7 +- src/cli-command-aliases.ts | 37 -------- src/cli/batch-steps.ts | 9 +- src/cli/parser/args.ts | 8 +- src/client/client-types.ts | 18 +++- src/command-aliases.ts | 43 +++++++++ src/commands/command-explain.ts | 8 +- src/commands/system/runtime/index.ts | 9 ++ src/commands/system/runtime/system.test.ts | 16 ++++ src/commands/system/runtime/system.ts | 25 +++++ src/contracts/navigation.ts | 12 +++ .../request-router-command-alias.test.ts | 93 +++++++++++++++++++ src/daemon/request-router.ts | 17 +++- 18 files changed, 322 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/batch-command-alias.test.ts delete mode 100644 src/cli-command-aliases.ts create mode 100644 src/command-aliases.ts create mode 100644 src/daemon/__tests__/request-router-command-alias.test.ts diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 02fd7f621..ccf99946d 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -226,7 +226,7 @@ classifying representative actions; it must not become a second prose registry: | Effect | Commands/actions | | --- | --- | -| `may-invalidate` | press, click, fill, longpress, type, focus, scroll, swipe, gesture, back, home, `tv-remote`, rotate, open/relaunch, trigger/push delivery, settings changes, install/reinstall, React Native overlay dismissal, and lifecycle operations that can replace the visible surface | +| `may-invalidate` | press, click, fill, longpress, type, focus, scroll, swipe, gesture, back, home, `tv-remote`, orientation, open/relaunch, trigger/push delivery, settings changes, install/reinstall, React Native overlay dismissal, and lifecycle operations that can replace the visible surface | | Conditional resolver | keyboard status preserves while dismiss/return/input invalidate; alert get/wait preserve while accept/dismiss invalidate; find reads preserve while click/fill/focus/type delegate to their leaf mutation | | `delegated` | batch, replay, and test/suite orchestrators; each nested leaf owns its transition | | `preserve` | snapshots and other observation, assertion, screenshot, recording, trace, logs, events, network inspection, performance, inventory, capability, lease, and transport-management operations unless a selected subaction directly manipulates the visible surface | @@ -234,7 +234,7 @@ classifying representative actions; it must not become a second prose registry: Clipboard reads and writes preserve the frame because pasteboard state alone does not change element identity. A later paste/type action is independently invalidating. -Generic routing is not an exception to the policy. `back`, `home`, `rotate`, `scroll`, `tv-remote`, +Generic routing is not an exception to the policy. `back`, `home`, `orientation`, `scroll`, `tv-remote`, and `app-switcher` all reach the generic daemon leaf and must cross the same transition there when they act. `app-switcher` is currently projected to the daemon by a direct writer but, unlike its generic-routed siblings, omits an explicit daemon descriptor facet and relies on the registry's diff --git a/src/__tests__/batch-command-alias.test.ts b/src/__tests__/batch-command-alias.test.ts new file mode 100644 index 000000000..d225bb611 --- /dev/null +++ b/src/__tests__/batch-command-alias.test.ts @@ -0,0 +1,35 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { readCliBatchStepsJson } from '../cli/batch-steps.ts'; +import { readStructuredBatchCommandName, normalizeBatchCommandName } from '../batch-policy.ts'; + +// Batch steps carry the wire command in structured data that never passes through +// the CLI token parser, so the shared command-alias map is applied where each +// batch layer first reads the command name. This keeps shipped batch data that +// still says `rotate` resolving to canonical `orientation` instead of failing as +// "not available through command batch". + +test('CLI batch parser resolves a deprecated rotate step to orientation', () => { + const [step] = readCliBatchStepsJson( + JSON.stringify([{ command: 'rotate', positionals: ['portrait'] }]), + ); + assert.equal(step?.command, 'orientation'); +}); + +test('CLI batch parser resolves a deprecated rotate alias case-insensitively', () => { + const [step] = readCliBatchStepsJson( + JSON.stringify([{ command: 'ROTATE', input: { orientation: 'landscape-left' } }]), + ); + assert.equal(step?.command, 'orientation'); +}); + +test('daemon batch policy accepts and normalizes a deprecated rotate step', () => { + assert.equal(normalizeBatchCommandName('rotate'), 'orientation'); + assert.equal(readStructuredBatchCommandName('rotate', 1), 'orientation'); +}); + +test('daemon batch policy leaves canonical and unknown names intact', () => { + assert.equal(normalizeBatchCommandName('orientation'), 'orientation'); + assert.equal(normalizeBatchCommandName('press'), 'press'); + assert.throws(() => readStructuredBatchCommandName('not-a-command', 1), /not available/); +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 4cea2261b..f08b038ba 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -166,6 +166,26 @@ test('client exposes narrowed result types for closed daemon projections', async assert.equal(triggerResult.transport, 'deep-link'); }); +test('deprecated client.command.rotate delegates to orientation and keeps the legacy action', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + action: 'orientation', + orientation: 'landscape-left', + message: 'Rotated to landscape-left', + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.command.rotate({ orientation: 'landscape-left' }); + + // The wrapper sends the canonical wire command... + assert.equal(setup.calls.at(-1)?.command, 'orientation'); + // ...and restores the shipped v0.18/v0.19 response contract for old consumers. + assert.equal(result.action, 'rotate'); + assert.equal(result.orientation, 'landscape-left'); +}); + function closedProjectionResponse(command: string): DaemonResponse { const data = closedProjectionResponses[command]; if (!data) throw new Error(`Unexpected command: ${command}`); diff --git a/src/__tests__/runtime-public.test.ts b/src/__tests__/runtime-public.test.ts index d11f870c5..ccb48b32e 100644 --- a/src/__tests__/runtime-public.test.ts +++ b/src/__tests__/runtime-public.test.ts @@ -259,6 +259,7 @@ test('internal backend, commands, and io modules are usable', () => { assert.equal(typeof commands.system.back, 'function'); assert.equal(typeof commands.system.home, 'function'); assert.equal(typeof commands.system.orientation, 'function'); + assert.equal(typeof commands.system.rotate, 'function'); assert.equal(typeof commands.system.keyboard, 'function'); assert.equal(typeof commands.system.clipboard, 'function'); assert.equal(typeof commands.system.settings, 'function'); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index e7243209d..0806d0139 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -53,8 +53,10 @@ import type { MaterializationReleaseOptions, MetroPrepareOptions, MetroPrepareResult, + OrientationCommandResult, PanOptions, FlingOptions, + RotateCommandResult, SwipeGestureOptions, PinchOptions, RotateGestureOptions, @@ -74,7 +76,7 @@ import type { ProjectedNavigationCommandClient } from './commands/system/navigat import { AppError } from './kernel/errors.ts'; type ProjectedSystemCommandClient = ProjectedNavigationCommandClient & - Pick; + Pick; export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, @@ -520,6 +522,17 @@ function buildProjectedSystemCommandClient( methods[method] = async (options = {}) => await executeCommand>(command as DaemonCommandName, options); } + // Deprecated (v0.18/v0.19): `rotate` was renamed to `orientation`. Retain a + // thin wrapper that delegates to `orientation` and restores the legacy + // `action: 'rotate'` response contract for existing consumers. + const orientation = methods.orientation; + if (!orientation) { + throw new Error('orientation client method missing from the system command family'); + } + methods.rotate = async (options = {}) => { + const result = (await orientation(options)) as OrientationCommandResult; + return { ...result, action: 'rotate' } satisfies RotateCommandResult; + }; return methods as unknown as ProjectedSystemCommandClient; } diff --git a/src/batch-policy.ts b/src/batch-policy.ts index 60a056b93..f307fbf53 100644 --- a/src/batch-policy.ts +++ b/src/batch-policy.ts @@ -1,5 +1,6 @@ import { deriveStructuredBatchCommandNames } from './core/command-descriptor/derive.ts'; import { commandDescriptors } from './core/command-descriptor/registry.ts'; +import { normalizeCommandAlias } from './command-aliases.ts'; import { AppError } from './kernel/errors.ts'; /** @@ -57,7 +58,11 @@ function isStructuredBatchCommandName(command: string): command is StructuredBat } export function normalizeBatchCommandName(command: unknown): string { - return typeof command === 'string' ? command.trim().toLowerCase() : ''; + // Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared + // map so structured batch data carrying a renamed command validates and runs + // against the canonical descriptor instead of failing as unavailable. + const raw = typeof command === 'string' ? command.trim().toLowerCase() : ''; + return raw ? normalizeCommandAlias(raw) : ''; } export function readStructuredBatchCommandName( diff --git a/src/cli-command-aliases.ts b/src/cli-command-aliases.ts deleted file mode 100644 index 552387ced..000000000 --- a/src/cli-command-aliases.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CliFlags } from './commands/cli-grammar/flag-types.ts'; - -type BooleanCliFlagKey = { - [Key in keyof CliFlags]-?: Exclude extends boolean ? Key : never; -}[keyof CliFlags]; - -export type CliCommandAlias = { - alias: string; - command: string; - impliedFlags?: readonly BooleanCliFlagKey[]; -}; - -const CLI_COMMAND_ALIASES: readonly CliCommandAlias[] = [ - { alias: 'long-press', command: 'longpress' }, - { alias: 'metrics', command: 'perf' }, - { alias: 'tap', command: 'press' }, - { alias: 'launch', command: 'open' }, - { alias: 'relaunch', command: 'open', impliedFlags: ['relaunch'] }, - // Deprecated: `rotate` collided with the `gesture rotate` two-finger gesture. - { alias: 'rotate', command: 'orientation' }, -]; - -const aliasByToken: ReadonlyMap = new Map( - CLI_COMMAND_ALIASES.map((entry) => [entry.alias, entry]), -); - -export function normalizeCliCommandAlias(command: string): string { - return aliasByToken.get(command.toLowerCase())?.command ?? command; -} - -export function cliCommandAlias(rawCommand: string): CliCommandAlias | undefined { - return aliasByToken.get(rawCommand.toLowerCase()); -} - -export function cliAliasesForCommand(command: string): CliCommandAlias[] { - return CLI_COMMAND_ALIASES.filter((entry) => entry.command === command); -} diff --git a/src/cli/batch-steps.ts b/src/cli/batch-steps.ts index a6f579ad9..5f64cc590 100644 --- a/src/cli/batch-steps.ts +++ b/src/cli/batch-steps.ts @@ -3,6 +3,7 @@ import { type SessionRuntimeHints } from '../kernel/contracts.ts'; import { parseBatchStepRuntime } from '../batch-contract.ts'; import { readInputFromCli } from '../commands/cli-grammar.ts'; import { isCommandName, type CommandName } from '../commands/command-metadata.ts'; +import { normalizeCommandAlias } from '../command-aliases.ts'; import type { CliFlags } from '../commands/cli-grammar/flag-types.ts'; import { AppError } from '../kernel/errors.ts'; import { isRecord } from '../utils/parsing.ts'; @@ -68,6 +69,8 @@ function readStructuredBatchStep( const { runtime: _runtime, ...rest } = step; return { ...rest, + // Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared map. + command: normalizeCommandAlias(rest.command), ...(runtime === undefined ? {} : { runtime }), }; } @@ -90,8 +93,10 @@ function readLegacyCliBatchStep(step: unknown, stepNumber: number): LegacyCliBat } function readLegacyCommand(value: unknown, stepNumber: number): CommandName { - const command = typeof value === 'string' ? value.trim().toLowerCase() : ''; - if (!command) throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} requires command.`); + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (!raw) throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} requires command.`); + // Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared map. + const command = normalizeCommandAlias(raw); if (isCommandName(command)) return command; throw new AppError( 'INVALID_ARGS', diff --git a/src/cli/parser/args.ts b/src/cli/parser/args.ts index 6eae61691..c2e2fc033 100644 --- a/src/cli/parser/args.ts +++ b/src/cli/parser/args.ts @@ -11,7 +11,7 @@ import { } from '../../cli-schema/command-schema.ts'; import { isFlagSupportedForCommand } from '../../cli-schema/option-schema.ts'; import { isKnownCliCommandName } from '../../command-catalog.ts'; -import { cliCommandAlias, normalizeCliCommandAlias } from '../../cli-command-aliases.ts'; +import { commandAlias, normalizeCommandAlias } from '../../command-aliases.ts'; import { formatUnknownFlagMessage, suggestCommandFor } from './command-suggestions.ts'; type ParsedArgs = { @@ -118,7 +118,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { function applyAliasImpliedFlags(rawCommand: string | null, flags: CliFlags): void { if (!rawCommand) return; - for (const key of cliCommandAlias(rawCommand)?.impliedFlags ?? []) { + for (const key of commandAlias(rawCommand)?.impliedFlags ?? []) { flags[key] = true; } } @@ -382,7 +382,3 @@ export async function usageForCommand(command: string): Promise { const { buildCommandUsageText } = await import('./cli-help.ts'); return buildCommandUsageText(normalizeCommandAlias(command)); } - -function normalizeCommandAlias(command: string): string { - return normalizeCliCommandAlias(command); -} diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 7b0f24154..5ee50a08d 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -20,6 +20,7 @@ import type { PlatformSelector, } from '../kernel/device.ts'; import type { BackMode } from '../contracts/back-mode.ts'; +import type { RotateCommandResult } from '../contracts/navigation.ts'; import type { ClickButton } from '../core/click-button.ts'; import type { RecordingExportQuality } from '../core/recording-export-quality.ts'; import type { RecordingScope } from '../contracts/recording-scope.ts'; @@ -76,6 +77,8 @@ export type { BackCommandResult, HomeCommandResult, OrientationCommandResult, + /** @deprecated Renamed to `OrientationCommandResult`. Retained until the next major. */ + RotateCommandResult, TvRemoteCommandResult, } from '../contracts/navigation.ts'; export type { ClipboardCommandResult } from '../contracts/clipboard.ts'; @@ -542,6 +545,9 @@ export type BackCommandOptions = DeviceCommandBaseOptions & NavigationCommandOpt export type OrientationCommandOptions = DeviceCommandBaseOptions & NavigationCommandOptions<'orientation'>; +/** @deprecated Renamed to `OrientationCommandOptions`. Retained until the next major. */ +export type RotateCommandOptions = OrientationCommandOptions; + export type AppSwitcherCommandOptions = DeviceCommandBaseOptions & NavigationCommandOptions<'app-switcher'>; @@ -597,7 +603,17 @@ type NonNavigationCommandClient = { }; export type AgentDeviceCommandClient = ProjectedNavigationCommandClient & - NonNavigationCommandClient; + NonNavigationCommandClient & + DeprecatedCommandClient; + +/** Renamed command methods retained for existing consumers until the next major. */ +type DeprecatedCommandClient = { + /** + * @deprecated Renamed to `orientation`. Delegates to it and returns the legacy + * `action: 'rotate'` response contract. Retained until the next major version. + */ + rotate: (options: RotateCommandOptions) => Promise; +}; type SelectorSnapshotCommandOptions = Pick; type FindSnapshotCommandOptions = Pick; diff --git a/src/command-aliases.ts b/src/command-aliases.ts new file mode 100644 index 000000000..85c4c02c0 --- /dev/null +++ b/src/command-aliases.ts @@ -0,0 +1,43 @@ +import type { CliFlags } from './commands/cli-grammar/flag-types.ts'; + +type BooleanCliFlagKey = { + [Key in keyof CliFlags]-?: Exclude extends boolean ? Key : never; +}[keyof CliFlags]; + +export type CommandAlias = { + alias: string; + command: string; + impliedFlags?: readonly BooleanCliFlagKey[]; +}; + +// The single source of truth for command-name aliases. It is applied at two +// ingress boundaries so an alias resolves the same way regardless of how a +// command arrives: CLI token parsing (`src/cli/parser/args.ts`) and the daemon +// request boundary (`src/daemon/request-router.ts`), which covers structured +// batch steps, recorded replay data, and older remote clients that send the +// wire command directly. `impliedFlags` is a CLI-parse-only concern. +const COMMAND_ALIASES: readonly CommandAlias[] = [ + { alias: 'long-press', command: 'longpress' }, + { alias: 'metrics', command: 'perf' }, + { alias: 'tap', command: 'press' }, + { alias: 'launch', command: 'open' }, + { alias: 'relaunch', command: 'open', impliedFlags: ['relaunch'] }, + // Deprecated: `rotate` collided with the `gesture rotate` two-finger gesture. + { alias: 'rotate', command: 'orientation' }, +]; + +const aliasByToken: ReadonlyMap = new Map( + COMMAND_ALIASES.map((entry) => [entry.alias, entry]), +); + +export function normalizeCommandAlias(command: string): string { + return aliasByToken.get(command.toLowerCase())?.command ?? command; +} + +export function commandAlias(rawCommand: string): CommandAlias | undefined { + return aliasByToken.get(rawCommand.toLowerCase()); +} + +export function aliasesForCommand(command: string): CommandAlias[] { + return COMMAND_ALIASES.filter((entry) => entry.command === command); +} diff --git a/src/commands/command-explain.ts b/src/commands/command-explain.ts index 7d981de6e..63d2dc3e9 100644 --- a/src/commands/command-explain.ts +++ b/src/commands/command-explain.ts @@ -1,5 +1,5 @@ import { listCliCommandNames } from '../command-catalog.ts'; -import { cliAliasesForCommand, normalizeCliCommandAlias } from '../cli-command-aliases.ts'; +import { aliasesForCommand, normalizeCommandAlias } from '../command-aliases.ts'; import { buildCommandUsage } from '../cli-schema/usage.ts'; import type { DaemonCommandRoute } from '../daemon/daemon-command-registry.ts'; import { commandDescriptors, type Command } from '../core/command-descriptor/registry.ts'; @@ -165,13 +165,13 @@ export function formatCommandExplanation( } function resolveDescriptor(query: string): CommandDescriptor | undefined { - const direct = descriptorByName.get(normalizeCliCommandAlias(query)); + const direct = descriptorByName.get(normalizeCommandAlias(query)); if (direct) return direct; return commandDescriptors.find((descriptor) => readCatalogKey(descriptor) === query); } function describeCliAliases(command: string): CommandAliasExplanation[] { - return cliAliasesForCommand(command).map((entry) => ({ + return aliasesForCommand(command).map((entry) => ({ alias: entry.alias, ...(entry.impliedFlags && entry.impliedFlags.length > 0 ? { impliedFlags: [...entry.impliedFlags] } @@ -240,7 +240,7 @@ function suggestCommands(query: string): string[] { const candidates = commandDescriptors.flatMap((descriptor) => [ descriptor.name, readCatalogKey(descriptor), - ...cliAliasesForCommand(descriptor.name).map((alias) => alias.alias), + ...aliasesForCommand(descriptor.name).map((alias) => alias.alias), ]); return [...new Set(candidates)] .map((candidate) => ({ candidate, distance: levenshtein(query, candidate) })) diff --git a/src/commands/system/runtime/index.ts b/src/commands/system/runtime/index.ts index be808dc84..50fb2a431 100644 --- a/src/commands/system/runtime/index.ts +++ b/src/commands/system/runtime/index.ts @@ -8,6 +8,7 @@ import { homeCommand, keyboardCommand, orientationCommand, + rotateCommand, settingsCommand, tvRemoteCommand, type SystemAlertCommandOptions, @@ -24,6 +25,8 @@ import { type SystemKeyboardCommandResult, type SystemOrientationCommandOptions, type SystemOrientationCommandResult, + type SystemRotateCommandOptions, + type SystemRotateCommandResult, type SystemSettingsCommandOptions, type SystemSettingsCommandResult, type SystemTvRemoteCommandOptions, @@ -34,6 +37,8 @@ export type SystemCommands = { back: RuntimeCommand; home: RuntimeCommand; orientation: RuntimeCommand; + /** @deprecated Renamed to `orientation`. Retained until the next major. */ + rotate: RuntimeCommand; keyboard: RuntimeCommand; clipboard: RuntimeCommand; settings: RuntimeCommand; @@ -49,6 +54,8 @@ export type BoundSystemCommands = { back: (options?: SystemBackCommandOptions) => Promise; home: (options?: SystemHomeCommandOptions) => Promise; orientation: BoundRuntimeCommand; + /** @deprecated Renamed to `orientation`. Retained until the next major. */ + rotate: BoundRuntimeCommand; keyboard: (options?: SystemKeyboardCommandOptions) => Promise; clipboard: BoundRuntimeCommand; settings: (options?: SystemSettingsCommandOptions) => Promise; @@ -63,6 +70,7 @@ export const systemCommands: SystemCommands = { back: backCommand, home: homeCommand, orientation: orientationCommand, + rotate: rotateCommand, keyboard: keyboardCommand, clipboard: clipboardCommand, settings: settingsCommand, @@ -76,6 +84,7 @@ export function bindSystemCommands(runtime: AgentDeviceRuntime): BoundSystemComm back: (options) => systemCommands.back(runtime, options), home: (options) => systemCommands.home(runtime, options), orientation: (options) => systemCommands.orientation(runtime, options), + rotate: (options) => systemCommands.rotate(runtime, options), keyboard: (options) => systemCommands.keyboard(runtime, options), clipboard: (options) => systemCommands.clipboard(runtime, options), settings: (options) => systemCommands.settings(runtime, options), diff --git a/src/commands/system/runtime/system.test.ts b/src/commands/system/runtime/system.test.ts index 0d917dadd..b89ccd747 100644 --- a/src/commands/system/runtime/system.test.ts +++ b/src/commands/system/runtime/system.test.ts @@ -87,6 +87,22 @@ test('runtime system commands validate options before backend calls', async () = assert.deepEqual(calls, []); }); +test('deprecated device.system.rotate delegates to orientation and keeps the legacy kind', async () => { + const calls: unknown[] = []; + const device = createAgentDevice({ + backend: createSystemBackend(calls), + artifacts: createLocalArtifactAdapter(), + policy: localCommandPolicy(), + }); + + const rotated = await device.system.rotate({ orientation: 'landscape-left' }); + + assert.equal(rotated.kind, 'systemRotated'); + assert.equal(rotated.orientation, 'landscape-left'); + // Reaches the same backend seam as the canonical command. + assert.deepEqual(calls, [{ command: 'setOrientation', orientation: 'landscape-left' }]); +}); + function createSystemBackend(calls: unknown[]): AgentDeviceBackend { return { platform: 'ios', diff --git a/src/commands/system/runtime/system.ts b/src/commands/system/runtime/system.ts index 43feba39e..8ce65ecf0 100644 --- a/src/commands/system/runtime/system.ts +++ b/src/commands/system/runtime/system.ts @@ -46,6 +46,18 @@ export type SystemOrientationCommandResult = { orientation: BackendDeviceOrientation; } & BackendResultEnvelope; +/** @deprecated Renamed to {@link SystemOrientationCommandOptions}. Retained until the next major. */ +export type SystemRotateCommandOptions = SystemOrientationCommandOptions; + +/** + * @deprecated Renamed to {@link SystemOrientationCommandResult}. This is the + * legacy `kind: 'systemRotated'` contract, retained until the next major. + */ +export type SystemRotateCommandResult = { + kind: 'systemRotated'; + orientation: BackendDeviceOrientation; +} & BackendResultEnvelope; + export type SystemKeyboardCommandOptions = CommandContext & { action?: 'status' | 'get' | 'dismiss' | 'enter' | 'return'; }; @@ -226,6 +238,19 @@ export const orientationCommand: RuntimeCommand< }; }; +/** + * @deprecated Renamed to {@link orientationCommand}. Delegates to it and + * restores the legacy `kind: 'systemRotated'` contract for existing consumers. + * Retained until the next major version. + */ +export const rotateCommand: RuntimeCommand< + SystemRotateCommandOptions, + SystemRotateCommandResult +> = async (runtime, options): Promise => { + const result = await orientationCommand(runtime, options); + return { ...result, kind: 'systemRotated' }; +}; + export const keyboardCommand: RuntimeCommand< SystemKeyboardCommandOptions | undefined, SystemKeyboardCommandResult diff --git a/src/contracts/navigation.ts b/src/contracts/navigation.ts index 2c6a37b42..4f3e37b51 100644 --- a/src/contracts/navigation.ts +++ b/src/contracts/navigation.ts @@ -32,6 +32,18 @@ export type OrientationCommandResult = { message: string; }; +/** + * @deprecated The `rotate` command was renamed to `orientation`. This is the + * legacy response contract (`action: 'rotate'`) that shipped in v0.18/v0.19, + * retained for existing SDK consumers until the next major version. New code + * should use {@link OrientationCommandResult}. + */ +export type RotateCommandResult = { + action: 'rotate'; + orientation: DeviceRotation; + message: string; +}; + /** `app-switcher` — `{ action: 'app-switcher', message: 'Opened app switcher' }`. */ export type AppSwitcherCommandResult = { action: 'app-switcher'; diff --git a/src/daemon/__tests__/request-router-command-alias.test.ts b/src/daemon/__tests__/request-router-command-alias.test.ts new file mode 100644 index 000000000..1942b1b03 --- /dev/null +++ b/src/daemon/__tests__/request-router-command-alias.test.ts @@ -0,0 +1,93 @@ +import { test, expect, vi, beforeEach } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; +}); + +vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); + +import { dispatchCommand } from '../../core/dispatch.ts'; +import { createRequestHandler } from '../request-router.ts'; +import type { SessionState } from '../types.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; + +const mockDispatch = vi.mocked(dispatchCommand); + +function makeIosSession(name: string): SessionState { + return { + name, + createdAt: Date.now(), + actions: [], + device: { + platform: 'apple', + target: 'mobile', + id: 'SIM-001', + name: 'iPhone 16', + kind: 'simulator', + booted: true, + simulatorSetPath: '/tmp/tenant-a/set', + }, + }; +} + +function createHandler(sessionStore: ReturnType) { + return createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + }); +} + +beforeEach(() => { + mockDispatch.mockReset(); + mockDispatch.mockResolvedValue({}); +}); + +// The daemon request boundary is the single compatibility seam for renamed +// commands. It covers command-data/RPC paths that never pass through the CLI +// parser: structured batch steps, recorded replay data, and older remote +// clients that send the wire command directly. +test('daemon boundary rewrites the deprecated rotate command to canonical orientation', async () => { + const sessionStore = makeSessionStore('agent-device-router-alias-'); + sessionStore.set('qa-ios', makeIosSession('qa-ios')); + + const handler = createHandler(sessionStore); + const response = await handler({ + token: 'test-token', + session: 'qa-ios', + command: 'rotate', + positionals: ['landscape-left'], + flags: {}, + meta: { requestId: 'req-rotate-alias' }, + }); + + expect(response.ok).toBe(true); + expect(mockDispatch).toHaveBeenCalledTimes(1); + // dispatchCommand(device, command, positionals, ...) — the daemon leaf sees the + // canonical name, so the descriptor/handler resolves instead of failing. + expect(mockDispatch.mock.calls[0]?.[1]).toBe('orientation'); + expect(mockDispatch.mock.calls[0]?.[2]).toEqual(['landscape-left']); +}); + +test('daemon boundary leaves canonical commands untouched', async () => { + const sessionStore = makeSessionStore('agent-device-router-alias-'); + sessionStore.set('qa-ios', makeIosSession('qa-ios')); + + const handler = createHandler(sessionStore); + await handler({ + token: 'test-token', + session: 'qa-ios', + command: 'orientation', + positionals: ['portrait'], + flags: {}, + meta: { requestId: 'req-orientation' }, + }); + + expect(mockDispatch.mock.calls[0]?.[1]).toBe('orientation'); +}); diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 3f59720f6..3779971ff 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -3,6 +3,7 @@ import { withTargetDeviceResolutionScope, } from '../core/dispatch-resolve.ts'; import { AppError, normalizeError, retriableForErrorCode } from '../kernel/errors.ts'; +import { normalizeCommandAlias } from '../command-aliases.ts'; import { supportedPlatformsForCommand } from '../core/capabilities.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import type { DaemonArtifactType, DaemonError, ResponseCost } from '../kernel/contracts.ts'; @@ -95,7 +96,13 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { } = deps; const { sessionStore, leaseRegistry } = deps; - async function handleRequest(req: DaemonRequest): Promise { + async function handleRequest(rawReq: DaemonRequest): Promise { + // Central compatibility boundary: resolve command-name aliases once, at the + // single point every request arrives — CLI, structured batch steps, recorded + // replay data, and older remote clients that send the wire command directly. + // This is why deprecated/renamed commands (e.g. `rotate` -> `orientation`) + // reach their canonical descriptor here without the CLI parser in the loop. + const req = canonicalizeRequestCommand(rawReq); const start = Date.now(); const debug = Boolean(req.meta?.debug || req.flags?.verbose); return await withDiagnosticsScope( @@ -358,6 +365,14 @@ function enrichDaemonError(command: string, error: DaemonError): DaemonError { // Phase 4 (agent-cost) success-path grafts: a leveled response view and an // opt-in cost block, both purely additive. With responseLevel `default` (or +// Resolves command-name aliases at the daemon boundary so a renamed/deprecated +// command reaches its canonical descriptor no matter the ingress path. Returns +// the original request object unchanged when the command is already canonical. +function canonicalizeRequestCommand(req: DaemonRequest): DaemonRequest { + const canonical = normalizeCommandAlias(req.command); + return canonical === req.command ? req : { ...req, command: canonical }; +} + // unset) AND no registered view AND no --cost, the original `response` object is // returned unchanged — byte-identical to today (Maestro `.ad` recompare safe). function applyAgentCostGrafts(