diff --git a/src/alert-contract.ts b/src/alert-contract.ts index 3e30e7ca4..d0ccd3d9a 100644 --- a/src/alert-contract.ts +++ b/src/alert-contract.ts @@ -2,7 +2,8 @@ export const ALERT_POLL_INTERVAL_MS = 300; export const DEFAULT_ALERT_TIMEOUT_MS = 10_000; export const ALERT_ACTION_RETRY_MS = 2_000; -export type AlertAction = 'get' | 'accept' | 'dismiss' | 'wait'; +export const ALERT_ACTIONS = ['get', 'accept', 'dismiss', 'wait'] as const; +export type AlertAction = (typeof ALERT_ACTIONS)[number]; export type AlertPlatform = 'android' | 'ios' | 'macos'; diff --git a/src/backend.ts b/src/backend.ts index 0518ee6c7..e83ef801b 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -8,8 +8,16 @@ import type { SnapshotOptions, SnapshotState, } from './utils/snapshot.ts'; +import type { NetworkIncludeMode } from './contracts.ts'; +import type { DeviceTarget, Platform, PlatformSelector } from './utils/device.ts'; +import type { BackMode } from './core/back-mode.ts'; +import type { RepeatedInput } from './commands/command-input.ts'; +import type { ClickButton } from './core/click-button.ts'; +import type { DeviceRotation } from './core/device-rotation.ts'; +import type { ScrollDirection } from './core/scroll-gesture.ts'; +import type { SessionSurface } from './core/session-surface.ts'; -export type AgentDeviceBackendPlatform = 'ios' | 'android' | 'macos' | 'linux'; +export type AgentDeviceBackendPlatform = Platform; export const BACKEND_CAPABILITY_NAMES = [ 'android.shell', @@ -71,7 +79,7 @@ export type BackendScreenshotOptions = { fullscreen?: boolean; overlayRefs?: boolean; stabilize?: boolean; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + surface?: SessionSurface; }; export type BackendScreenshotResult = { @@ -81,14 +89,10 @@ export type BackendScreenshotResult = { export type BackendActionResult = Record | void; -export type BackendDeviceOrientation = - | 'portrait' - | 'portrait-upside-down' - | 'landscape-left' - | 'landscape-right'; +export type BackendDeviceOrientation = DeviceRotation; export type BackendBackOptions = { - mode?: 'in-app' | 'system'; + mode?: BackMode; }; export type BackendKeyboardOptions = { @@ -96,7 +100,7 @@ export type BackendKeyboardOptions = { }; export type BackendKeyboardResult = { - platform?: 'android' | 'ios' | 'macos' | 'linux'; + platform?: Platform; action?: BackendKeyboardOptions['action']; visible?: boolean; inputType?: string | null; @@ -133,13 +137,8 @@ export type BackendAlertResult = timedOut?: boolean; }; -export type BackendTapOptions = { - button?: 'primary' | 'secondary' | 'middle'; - count?: number; - intervalMs?: number; - holdMs?: number; - jitterPx?: number; - doubleTap?: boolean; +export type BackendTapOptions = RepeatedInput & { + button?: ClickButton; }; export type BackendFillOptions = { @@ -164,7 +163,7 @@ export type BackendScrollTarget = }; export type BackendScrollOptions = { - direction: 'up' | 'down' | 'left' | 'right'; + direction: ScrollDirection; amount?: number; pixels?: number; }; @@ -234,8 +233,8 @@ export type BackendAppEvent = { }; export type BackendDeviceFilter = { - platform?: AgentDeviceBackendPlatform | 'apple'; - target?: 'mobile' | 'tv' | 'desktop'; + platform?: PlatformSelector; + target?: DeviceTarget; kind?: 'simulator' | 'emulator' | 'device' | 'desktop'; }; @@ -340,7 +339,7 @@ export type BackendReadLogsResult = { notes?: readonly string[]; }; -export type BackendNetworkIncludeMode = 'summary' | 'headers' | 'body' | 'all'; +export type BackendNetworkIncludeMode = NetworkIncludeMode; export type BackendNetworkEntry = { timestamp?: string; diff --git a/src/client-types.ts b/src/client-types.ts index b805013b3..eb0212fb7 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -4,10 +4,26 @@ import type { DaemonLockPolicy, DaemonRequest, DaemonResponse, + DaemonServerMode, + DaemonTransportPreference, LeaseBackend, + NetworkIncludeMode, + SessionIsolationMode, SessionRuntimeHints, } from './contracts.ts'; import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts'; +import type { BackMode } from './core/back-mode.ts'; +import type { ClickButton } from './core/click-button.ts'; +import type { DeviceRotation } from './core/device-rotation.ts'; +import type { + ScrollDirection, + SwipePattern, + SwipePreset, + TransformGestureParams, +} from './core/scroll-gesture.ts'; +import type { ScrollInputDirection } from './commands/interaction-gestures.ts'; +import type { LogAction } from './commands/log-command-contract.ts'; +import type { SessionSurface } from './core/session-surface.ts'; import type { FindLocator } from './utils/finders.ts'; import type { AndroidSnapshotBackendMetadata } from './platforms/android/snapshot-types.ts'; import type { @@ -26,17 +42,13 @@ import type { AppsFilter } from './commands/app-inventory-contract.ts'; import type { ScreenshotRequestFlags } from './commands/capture-screenshot-options.ts'; import type { PerfAction, PerfArea } from './commands/perf-command-contract.ts'; import type { DaemonBatchStep } from './core/batch.ts'; -import type { AlertInfo } from './alert-contract.ts'; +import type { AlertAction, AlertInfo } from './alert-contract.ts'; export type { FindLocator } from './utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; export type { AppsFilter } from './commands/app-inventory-contract.ts'; export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from './alert-contract.ts'; -type DaemonTransportMode = 'auto' | 'socket' | 'http'; -type DaemonServerMode = 'socket' | 'http' | 'dual'; -type SessionIsolationMode = 'none' | 'tenant'; - export type AgentDeviceDaemonTransport = ( req: Omit, ) => Promise; @@ -49,7 +61,7 @@ export type AgentDeviceClientConfig = { stateDir?: string; daemonBaseUrl?: string; daemonAuthToken?: string; - daemonTransport?: DaemonTransportMode; + daemonTransport?: DaemonTransportPreference; daemonServerMode?: DaemonServerMode; tenant?: string; sessionIsolation?: SessionIsolationMode; @@ -175,7 +187,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { app?: string; url?: string; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + surface?: SessionSurface; activity?: string; launchConsole?: string; launchArgs?: string[]; @@ -333,7 +345,7 @@ export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { fullscreen?: boolean; maxSize?: number; stabilize?: boolean; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + surface?: SessionSurface; }; export type CaptureScreenshotResult = { @@ -379,20 +391,20 @@ type WaitCommandTarget = export type WaitCommandOptions = DeviceCommandBaseOptions & WaitCommandTarget; export type AlertCommandOptions = DeviceCommandBaseOptions & { - action?: 'get' | 'accept' | 'dismiss' | 'wait'; + action?: AlertAction; timeoutMs?: number; }; export type AppStateCommandOptions = DeviceCommandBaseOptions; export type BackCommandOptions = DeviceCommandBaseOptions & { - mode?: 'in-app' | 'system'; + mode?: BackMode; }; export type HomeCommandOptions = DeviceCommandBaseOptions; export type RotateCommandOptions = DeviceCommandBaseOptions & { - orientation: 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right'; + orientation: DeviceRotation; }; export type AppSwitcherCommandOptions = DeviceCommandBaseOptions; @@ -441,11 +453,11 @@ export type AppStateCommandResult = DaemonResponseData & { package?: string; activity?: string; source?: 'session'; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + surface?: SessionSurface; }; export type BackCommandResult = CommandActionResult<'back'> & { - mode?: 'in-app' | 'system'; + mode?: BackMode; }; export type HomeCommandResult = CommandActionResult<'home'>; @@ -569,7 +581,7 @@ export type ClickOptions = ClientCommandBaseOptions & SelectorSnapshotCommandOptions & InteractionTarget & RepeatedPressOptions & { - button?: 'primary' | 'secondary' | 'middle'; + button?: ClickButton; }; export type PressOptions = ClientCommandBaseOptions & @@ -589,7 +601,7 @@ export type SwipeOptions = ClientCommandBaseOptions & { durationMs?: number; count?: number; pauseMs?: number; - pattern?: 'one-way' | 'ping-pong'; + pattern?: SwipePattern; }; export type PanOptions = ClientCommandBaseOptions & { @@ -601,7 +613,7 @@ export type PanOptions = ClientCommandBaseOptions & { }; export type FlingOptions = ClientCommandBaseOptions & { - direction: 'up' | 'down' | 'left' | 'right'; + direction: ScrollDirection; x: number; y: number; distance?: number; @@ -609,7 +621,7 @@ export type FlingOptions = ClientCommandBaseOptions & { }; export type SwipeGestureOptions = ClientCommandBaseOptions & { - preset: 'left' | 'right' | 'left-edge' | 'right-edge'; + preset: SwipePreset; durationMs?: number; }; @@ -631,7 +643,7 @@ export type FillOptions = ClientCommandBaseOptions & }; export type ScrollOptions = ClientCommandBaseOptions & { - direction: 'up' | 'down' | 'left' | 'right' | 'top' | 'bottom'; + direction: ScrollInputDirection; amount?: number; pixels?: number; }; @@ -649,15 +661,7 @@ export type RotateGestureOptions = ClientCommandBaseOptions & { velocity?: number; }; -export type TransformGestureOptions = ClientCommandBaseOptions & { - x: number; - y: number; - dx: number; - dy: number; - scale: number; - degrees: number; - durationMs?: number; -}; +export type TransformGestureOptions = ClientCommandBaseOptions & TransformGestureParams; export type GetOptions = ClientCommandBaseOptions & SelectorSnapshotCommandOptions & @@ -742,7 +746,7 @@ export type PerfOptions = ClientCommandBaseOptions & { }; export type LogsOptions = AgentDeviceRequestOverrides & { - action?: 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear'; + action?: LogAction; message?: string; restart?: boolean; }; @@ -750,7 +754,7 @@ export type LogsOptions = AgentDeviceRequestOverrides & { export type NetworkOptions = AgentDeviceRequestOverrides & { action?: 'dump' | 'log'; limit?: number; - include?: 'summary' | 'headers' | 'body' | 'all'; + include?: NetworkIncludeMode; }; type RecordingQuality = 5 | 6 | 7 | 8 | 9 | 10; @@ -844,9 +848,9 @@ type CommandExecutionOptions = Partial & { jitterPx?: number; pixels?: number; doubleTap?: boolean; - clickButton?: 'primary' | 'secondary' | 'middle'; + clickButton?: ClickButton; pauseMs?: number; - pattern?: 'one-way' | 'ping-pong'; + pattern?: SwipePattern; headless?: boolean; restart?: boolean; replayUpdate?: boolean; @@ -863,7 +867,7 @@ type CommandExecutionOptions = Partial & { shardSplit?: number; findFirst?: boolean; findLast?: boolean; - networkInclude?: 'summary' | 'headers' | 'body' | 'all'; + networkInclude?: NetworkIncludeMode; batchOnError?: 'stop'; batchMaxSteps?: number; batchSteps?: DaemonBatchStep[]; @@ -874,7 +878,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & CommandExecutionOptions & { runtime?: SessionRuntimeHints; overlayRefs?: boolean; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + surface?: SessionSurface; activity?: string; launchConsole?: string; launchArgs?: string[]; @@ -882,7 +886,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & shutdown?: boolean; saveScript?: boolean | string; noRecord?: boolean; - backMode?: 'in-app' | 'system'; + backMode?: BackMode; metroHost?: string; metroPort?: number; bundleUrl?: string; diff --git a/src/command-catalog.ts b/src/command-catalog.ts index f0e8875f8..6ba34b6ee 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -67,8 +67,9 @@ const LOCAL_CLI_COMMANDS = { session: 'session', } as const; -const GESTURE_SUBCOMMANDS = ['pan', 'fling', 'swipe', 'pinch', 'rotate', 'transform'] as const; -export const GESTURE_SUBCOMMAND_ERROR = `gesture requires one of: ${GESTURE_SUBCOMMANDS.join(', ')}`; +export const GESTURE_KINDS = ['pan', 'fling', 'swipe', 'pinch', 'rotate', 'transform'] as const; +export type GestureKind = (typeof GESTURE_KINDS)[number]; +export const GESTURE_SUBCOMMAND_ERROR = `gesture requires one of: ${GESTURE_KINDS.join(', ')}`; export type PublicCommandName = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]; export type LocalCliCommandName = (typeof LOCAL_CLI_COMMANDS)[keyof typeof LOCAL_CLI_COMMANDS]; diff --git a/src/commands/admin.ts b/src/commands/admin.ts index b699153cf..b41139b21 100644 --- a/src/commands/admin.ts +++ b/src/commands/admin.ts @@ -1,5 +1,4 @@ import type { - BackendActionResult, BackendCommandContext, BackendDeviceFilter, BackendDeviceInfo, @@ -10,7 +9,11 @@ import type { import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import { AppError } from '../utils/errors.ts'; import { successText } from '../utils/success-text.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +import { + toBackendResult, + type BackendResultEnvelope, + type RuntimeCommand, +} from './runtime-types.ts'; import { resolveCommandInput } from './io-policy.ts'; import { toBackendContext } from './selector-read-utils.ts'; import { normalizeOptionalText, requireText } from './text.ts'; @@ -31,9 +34,7 @@ export type AdminBootCommandOptions = CommandContext & { export type AdminBootCommandResult = { kind: 'deviceBooted'; target?: BackendDeviceTarget; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type AdminInstallCommandOptions = CommandContext & { app: string; @@ -58,9 +59,7 @@ export type AdminInstallCommandResult = { launchTarget?: string; installablePath?: string; archivePath?: string; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export const devicesCommand: RuntimeCommand< AdminDevicesCommandOptions | undefined, @@ -283,7 +282,3 @@ function formatSource(source: BackendInstallSource): string { if (source.kind === 'uploadedArtifact') return source.id; return source.url; } - -function toBackendResult(result: BackendActionResult): Record | undefined { - return result && typeof result === 'object' ? result : undefined; -} diff --git a/src/commands/apps.ts b/src/commands/apps.ts index 218c9a884..25833982d 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -1,5 +1,4 @@ import type { - BackendActionResult, BackendAppInfo, BackendAppListFilter, BackendAppState, @@ -13,7 +12,11 @@ import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; import { AppError } from '../utils/errors.ts'; import { successText } from '../utils/success-text.ts'; import { resolveCommandInput } from './io-policy.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +import { + toBackendResult, + type BackendResultEnvelope, + type RuntimeCommand, +} from './runtime-types.ts'; import { normalizeOptionalText, requireText } from './text.ts'; const APP_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/; @@ -30,9 +33,7 @@ export type OpenAppCommandResult = { kind: 'appOpened'; target: BackendOpenTarget; relaunch: boolean; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type CloseAppCommandOptions = CommandContext & { app?: string; @@ -41,9 +42,7 @@ export type CloseAppCommandOptions = CommandContext & { export type CloseAppCommandResult = { kind: 'appClosed'; app?: string; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type ListAppsCommandOptions = CommandContext & { filter?: BackendAppListFilter; @@ -80,9 +79,7 @@ export type PushAppCommandResult = { kind: 'appPushed'; app: string; inputKind: 'json' | 'file'; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type TriggerAppEventCommandOptions = CommandContext & { name: string; @@ -93,9 +90,7 @@ export type TriggerAppEventCommandResult = { kind: 'appEventTriggered'; name: string; payload?: Record; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export const openAppCommand: RuntimeCommand = async ( runtime, @@ -373,7 +368,3 @@ function toAppBackendContext( metadata: options.metadata, }; } - -function toBackendResult(result: BackendActionResult): Record | undefined { - return result && typeof result === 'object' ? result : undefined; -} diff --git a/src/commands/cli-grammar/capture.ts b/src/commands/cli-grammar/capture.ts index 3f5200bca..6cca84ca2 100644 --- a/src/commands/cli-grammar/capture.ts +++ b/src/commands/cli-grammar/capture.ts @@ -6,6 +6,7 @@ import type { WaitCommandOptions, } from '../../client-types.ts'; import { parseTimeout } from '../../daemon/handlers/parse-utils.ts'; +import type { AlertAction } from '../../alert-contract.ts'; import { splitSelectorFromArgs, tryParseSelectorChain } from '../../daemon/selectors.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; @@ -174,9 +175,7 @@ function readAlertInput(positionals: string[]): Record { return compactRecord({ action, timeoutMs }); } -function readAlertAction( - value: string | undefined, -): 'get' | 'accept' | 'dismiss' | 'wait' | undefined { +function readAlertAction(value: string | undefined): AlertAction | undefined { const action = value?.toLowerCase(); if ( action === undefined || diff --git a/src/commands/cli-grammar/common.ts b/src/commands/cli-grammar/common.ts index 47ef3c75f..c513337be 100644 --- a/src/commands/cli-grammar/common.ts +++ b/src/commands/cli-grammar/common.ts @@ -6,7 +6,7 @@ import type { import { splitSelectorFromArgs } from '../../daemon/selectors.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; -import { compactRecord } from '../command-input.ts'; +import { compactRecord, type SelectorSnapshotInput } from '../command-input.ts'; import type { DaemonWriter, SelectionOptions, @@ -77,11 +77,7 @@ export function selectorSnapshotInputFromFlags(flags: CliFlags): Record; -function readBackMode(value: unknown): 'in-app' | 'system' | undefined { +function readBackMode(value: unknown): BackMode | undefined { return value === 'in-app' || value === 'system' ? value : undefined; } diff --git a/src/commands/cli-grammar/types.ts b/src/commands/cli-grammar/types.ts index cc8952ef4..d966314d8 100644 --- a/src/commands/cli-grammar/types.ts +++ b/src/commands/cli-grammar/types.ts @@ -1,5 +1,6 @@ import type { InteractionTarget, InternalRequestOptions } from '../../client-types.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; +import type { ClickButton } from '../../core/click-button.ts'; export type DaemonCommandRequest = { command: string; @@ -35,7 +36,7 @@ export type CommandInput = Omit kind?: string; locator?: string; mode?: 'in-app' | 'system' | 'full' | 'limited'; - button?: 'primary' | 'secondary' | 'middle'; + button?: ClickButton; first?: boolean; last?: boolean; maxSteps?: number; diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index 1afe8f7ac..732415aa7 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -1,5 +1,10 @@ import type { MetroPrepareOptions, RecordOptions } from '../client-types.ts'; import type { DaemonInstallSource } from '../contracts.ts'; +import { ALERT_ACTIONS } from '../alert-contract.ts'; +import { BACK_MODES } from '../core/back-mode.ts'; +import { DEVICE_ROTATIONS } from '../core/device-rotation.ts'; +import { SESSION_SURFACES } from '../core/session-surface.ts'; +import { LOG_ACTION_VALUES } from './log-command-contract.ts'; import { requireCommandDescription } from './command-descriptions.ts'; import { booleanField, @@ -20,18 +25,8 @@ import { import { defineFieldCommandMetadata } from './field-command-contract.ts'; import { PERF_ACTION_VALUES, PERF_AREA_VALUES } from './perf-command-contract.ts'; -const SURFACE_VALUES = ['app', 'frontmost-app', 'desktop', 'menubar'] as const; const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const; -const ALERT_ACTION_VALUES = ['get', 'accept', 'dismiss', 'wait'] as const; -const BACK_MODE_VALUES = ['in-app', 'system'] as const; -const ORIENTATION_VALUES = [ - 'portrait', - 'portrait-upside-down', - 'landscape-left', - 'landscape-right', -] as const; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; -const LOG_ACTION_VALUES = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const; const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; const NETWORK_INCLUDE_VALUES = ['summary', 'headers', 'body', 'all'] as const; const START_STOP_VALUES = ['start', 'stop'] as const; @@ -57,7 +52,7 @@ export const clientCommandMetadata = [ defineClientCommandMetadata('open', { app: stringField('App name, bundle id, package, or URL.'), url: stringField('Optional URL passed with an app shell.'), - surface: enumField(SURFACE_VALUES), + surface: enumField(SESSION_SURFACES), activity: stringField('Android activity name.'), launchConsole: stringField('Launch console mode.'), launchArgs: stringArrayField( @@ -114,7 +109,7 @@ export const clientCommandMetadata = [ fullscreen: booleanField(), maxSize: integerField(), stabilize: booleanField(), - surface: enumField(SURFACE_VALUES), + surface: enumField(SESSION_SURFACES), }), defineClientCommandMetadata('diff', { kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), @@ -137,16 +132,16 @@ export const clientCommandMetadata = [ raw: booleanField(), }), defineClientCommandMetadata('alert', { - action: enumField(ALERT_ACTION_VALUES), + action: enumField(ALERT_ACTIONS), timeoutMs: integerField(), }), defineClientCommandMetadata('appstate', {}), defineClientCommandMetadata('back', { - mode: enumField(BACK_MODE_VALUES), + mode: enumField(BACK_MODES), }), defineClientCommandMetadata('home', {}), defineClientCommandMetadata('rotate', { - orientation: requiredField(enumField(ORIENTATION_VALUES)), + orientation: requiredField(enumField(DEVICE_ROTATIONS)), }), defineClientCommandMetadata('app-switcher', {}), defineClientCommandMetadata('keyboard', { diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts index 0a083ee3b..0daffc730 100644 --- a/src/commands/command-input.ts +++ b/src/commands/command-input.ts @@ -4,11 +4,16 @@ import type { ElementTarget, InteractionTarget, } from '../client-types.ts'; -import type { DeviceTarget, PlatformSelector } from '../utils/device.ts'; +import { DEVICE_TARGETS, type DeviceTarget, type PlatformSelector } from '../utils/device.ts'; import type { JsonSchema } from './command-contract.ts'; -const PLATFORM_VALUES = ['ios', 'android', 'macos', 'linux', 'apple'] as const; -const DEVICE_TARGET_VALUES = ['mobile', 'tv', 'desktop'] as const; +const PLATFORM_VALUES = [ + 'ios', + 'android', + 'macos', + 'linux', + 'apple', +] as const satisfies readonly PlatformSelector[]; const INTERACTION_TARGET_KINDS = ['ref', 'selector', 'point'] as const; export type CommonCommandInput = Pick< @@ -295,9 +300,9 @@ function readDeviceTarget( record: Record, options: CommonInputOptions, ): DeviceTarget | undefined { - const deviceTarget = optionalEnum(record, 'deviceTarget', DEVICE_TARGET_VALUES); + const deviceTarget = optionalEnum(record, 'deviceTarget', DEVICE_TARGETS); if (options.readTargetAlias === false || record.target === undefined) return deviceTarget; - const targetAlias = optionalEnum(record, 'target', DEVICE_TARGET_VALUES); + const targetAlias = optionalEnum(record, 'target', DEVICE_TARGETS); if (deviceTarget !== undefined && targetAlias !== deviceTarget) { throw new Error('Expected target alias to match deviceTarget when both are set.'); } @@ -564,12 +569,12 @@ function commonProperties(): Record { }, deviceTarget: { type: 'string', - enum: DEVICE_TARGET_VALUES, + enum: DEVICE_TARGETS, description: 'Device target form. Maps to the CLI --target flag.', }, target: { type: 'string', - enum: DEVICE_TARGET_VALUES, + enum: DEVICE_TARGETS, description: 'Alias for deviceTarget on commands without a UI target field. Interaction commands reserve target for the UI element.', }, diff --git a/src/commands/interaction-command-metadata.ts b/src/commands/interaction-command-metadata.ts index 8de331669..b289bc89e 100644 --- a/src/commands/interaction-command-metadata.ts +++ b/src/commands/interaction-command-metadata.ts @@ -1,5 +1,6 @@ import { requireCommandDescription } from './command-descriptions.ts'; import { defineCommandMetadata } from './command-contract.ts'; +import { GESTURE_KINDS } from '../command-catalog.ts'; import { booleanField, elementTargetField, @@ -26,11 +27,16 @@ import { type PointInput, } from './command-input.ts'; import { defineFieldCommandMetadata } from './field-command-contract.ts'; +import { CLICK_BUTTONS } from '../core/click-button.ts'; +import { + SCROLL_DIRECTIONS, + SWIPE_PATTERNS, + SWIPE_PRESETS, + type ScrollDirection, + type SwipePreset, +} from '../core/scroll-gesture.ts'; +import { SCROLL_INPUT_DIRECTIONS } from './interaction-gestures.ts'; -const CLICK_BUTTON_VALUES = ['primary', 'secondary', 'middle'] as const; -const GESTURE_KIND_VALUES = ['pan', 'fling', 'swipe', 'pinch', 'rotate', 'transform'] as const; -const GESTURE_DIRECTION_VALUES = ['up', 'down', 'left', 'right'] as const; -const GESTURE_SWIPE_PRESET_VALUES = ['left', 'right', 'left-edge', 'right-edge'] as const; const FIND_ACTION_VALUES = [ 'click', 'focus', @@ -42,15 +48,10 @@ const FIND_ACTION_VALUES = [ 'type', ] as const; const FIND_LOCATOR_VALUES = ['any', 'text', 'label', 'value', 'role', 'id'] as const; -const SCROLL_DIRECTION_VALUES = ['up', 'down', 'left', 'right', 'top', 'bottom'] as const; -const SWIPE_PATTERN_VALUES = ['one-way', 'ping-pong'] as const; const clickFields = { target: requiredField(interactionTargetField()), - button: enumField( - CLICK_BUTTON_VALUES, - 'Pointer button for platforms that support mouse buttons.', - ), + button: enumField(CLICK_BUTTONS, 'Pointer button for platforms that support mouse buttons.'), ...selectorSnapshotFields(), ...repeatedFields(), }; @@ -80,7 +81,7 @@ const swipeFields = { durationMs: integerField('Swipe duration in milliseconds.', { min: 0 }), count: integerField('Number of swipe repetitions.', { min: 1 }), pauseMs: integerField('Pause between repeated swipes.', { min: 0 }), - pattern: enumField(SWIPE_PATTERN_VALUES), + pattern: enumField(SWIPE_PATTERNS), }; const focusFields = { @@ -94,7 +95,7 @@ const typeFields = { }; const scrollFields = { - direction: requiredField(enumField(SCROLL_DIRECTION_VALUES)), + direction: requiredField(enumField(SCROLL_INPUT_DIRECTIONS)), amount: numberField('Platform scroll amount.'), pixels: integerField('Pixel scroll amount.', { min: 0 }), }; @@ -127,9 +128,9 @@ const findFields = { }; const gestureFields = { - kind: requiredField(enumField(GESTURE_KIND_VALUES, 'Gesture variant.')), - direction: enumField(GESTURE_DIRECTION_VALUES, 'Fling direction.'), - preset: enumField(GESTURE_SWIPE_PRESET_VALUES, 'Swipe preset.'), + kind: requiredField(enumField(GESTURE_KINDS, 'Gesture variant.')), + direction: enumField(SCROLL_DIRECTIONS, 'Fling direction.'), + preset: enumField(SWIPE_PRESETS, 'Swipe preset.'), origin: pointField('Gesture origin point.'), delta: pointField('Movement delta for pan or transform gestures.'), distance: integerField('Fling distance.', { min: 0 }), @@ -154,7 +155,7 @@ export type PanInput = CommonCommandInput & { export type FlingInput = CommonCommandInput & { kind: 'fling'; - direction: 'up' | 'down' | 'left' | 'right'; + direction: ScrollDirection; origin: PointInput; distance?: number; durationMs?: number; @@ -162,7 +163,7 @@ export type FlingInput = CommonCommandInput & { export type SwipeGestureInput = CommonCommandInput & { kind: 'swipe'; - preset: 'left' | 'right' | 'left-edge' | 'right-edge'; + preset: SwipePreset; durationMs?: number; }; @@ -234,7 +235,7 @@ export const interactionCommandMetadata = [ function readGestureInput(input: unknown): GestureInput { const record = readInputRecord(input); const common = readCommonInput(record); - const kind = requiredEnum(record, 'kind', GESTURE_KIND_VALUES); + const kind = requiredEnum(record, 'kind', GESTURE_KINDS); if (kind === 'pan') { return { ...common, @@ -248,7 +249,7 @@ function readGestureInput(input: unknown): GestureInput { return { ...common, kind, - direction: requiredEnum(record, 'direction', GESTURE_DIRECTION_VALUES), + direction: requiredEnum(record, 'direction', SCROLL_DIRECTIONS), origin: readPoint(record, 'origin'), distance: optionalInteger(record, 'distance', { min: 0 }), durationMs: optionalInteger(record, 'durationMs', { min: 0 }), @@ -258,7 +259,7 @@ function readGestureInput(input: unknown): GestureInput { return { ...common, kind, - preset: requiredEnum(record, 'preset', GESTURE_SWIPE_PRESET_VALUES), + preset: requiredEnum(record, 'preset', SWIPE_PRESETS), durationMs: optionalInteger(record, 'durationMs', { min: 0 }), }; } diff --git a/src/commands/interaction-gestures.ts b/src/commands/interaction-gestures.ts index 0bd3deb41..7f324af68 100644 --- a/src/commands/interaction-gestures.ts +++ b/src/commands/interaction-gestures.ts @@ -4,6 +4,8 @@ import { centerOfRect } from '../utils/snapshot.ts'; import { buildSwipePresetGesturePlan, parseSwipePreset, + type GestureReferenceFrame, + type ScrollDirection, type SwipePreset, } from '../core/scroll-gesture.ts'; import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; @@ -18,7 +20,11 @@ import { type ScrollEdgeState, type ScrollEdgeTarget, } from '../utils/scroll-edge-state.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +import { + toBackendResult, + type BackendResultEnvelope, + type RuntimeCommand, +} from './runtime-types.ts'; import { assertSupportedInteractionSurface, captureInteractionSnapshot, @@ -32,10 +38,7 @@ export type FocusCommandOptions = CommandContext & { target: InteractionTarget; }; -export type FocusCommandResult = ResolvedInteractionTarget & { - backendResult?: Record; - message?: string; -}; +export type FocusCommandResult = ResolvedInteractionTarget & BackendResultEnvelope; export type LongPressCommandOptions = CommandContext & { target: InteractionTarget; @@ -44,12 +47,11 @@ export type LongPressCommandOptions = CommandContext & { export type LongPressCommandResult = ResolvedInteractionTarget & { durationMs?: number; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; -export type GestureDirection = 'up' | 'down' | 'left' | 'right'; -export type ScrollInputDirection = GestureDirection | 'top' | 'bottom'; +export type GestureDirection = ScrollDirection; +export const SCROLL_INPUT_DIRECTIONS = ['up', 'down', 'left', 'right', 'top', 'bottom'] as const; +export type ScrollInputDirection = (typeof SCROLL_INPUT_DIRECTIONS)[number]; export type ScrollTarget = | InteractionTarget @@ -107,9 +109,7 @@ export type SwipeCommandResult = { distance?: number; durationMs?: number; fromTarget?: ResolvedInteractionTarget | { kind: 'viewport' }; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type PinchCommandOptions = CommandContext & { scale: number; @@ -121,9 +121,7 @@ export type PinchCommandResult = { scale: number; center?: Point; centerTarget?: ResolvedInteractionTarget; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export const focusCommand: RuntimeCommand = async ( runtime, @@ -539,10 +537,7 @@ function resolveSnapshotViewport(nodes: SnapshotState['nodes']): Rect { }; } -function resolveSnapshotReferenceFrame(nodes: SnapshotState['nodes']): { - referenceWidth: number; - referenceHeight: number; -} { +function resolveSnapshotReferenceFrame(nodes: SnapshotState['nodes']): GestureReferenceFrame { const viewport = resolveSnapshotViewport(nodes); return { referenceWidth: viewport.width, @@ -553,7 +548,3 @@ function resolveSnapshotReferenceFrame(nodes: SnapshotState['nodes']): { function isUsableRect(rect: SnapshotNode['rect']): rect is NonNullable { return Boolean(rect && rect.width > 0 && rect.height > 0); } - -function toBackendResult(result: unknown): Record | undefined { - return result && typeof result === 'object' ? (result as Record) : undefined; -} diff --git a/src/commands/interactions.ts b/src/commands/interactions.ts index e0d9737b4..fb8a34685 100644 --- a/src/commands/interactions.ts +++ b/src/commands/interactions.ts @@ -7,7 +7,12 @@ import { successText } from '../utils/success-text.ts'; import { findMistargetedTypeRefToken } from '../utils/type-target-warning.ts'; import type { ResolvedTarget } from './selector-read.ts'; import { toBackendContext } from './selector-read-utils.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +import { + toBackendResult, + type BackendResultEnvelope, + type RuntimeCommand, +} from './runtime-types.ts'; +import type { RepeatedInput } from './command-input.ts'; import { type InteractionTarget, type ResolvedInteractionTarget, @@ -42,15 +47,11 @@ export type { ResolvedInteractionTarget, } from './interaction-resolution.ts'; -export type PressCommandOptions = CommandContext & { - target: InteractionTarget; - button?: ClickButton; - count?: number; - intervalMs?: number; - holdMs?: number; - jitterPx?: number; - doubleTap?: boolean; -}; +export type PressCommandOptions = CommandContext & + RepeatedInput & { + target: InteractionTarget; + button?: ClickButton; + }; export type ClickCommandOptions = PressCommandOptions; @@ -79,9 +80,7 @@ export type TypeTextCommandResult = { kind: 'text'; text: string; delayMs: number; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export const pressCommand: RuntimeCommand = async ( runtime, @@ -191,10 +190,6 @@ async function tapCommand( }; } -function toBackendResult(result: unknown): Record | undefined { - return result && typeof result === 'object' ? (result as Record) : undefined; -} - function formatTargetForWarning(result: { kind: FillCommandResult['kind']; target?: ResolvedTarget; diff --git a/src/commands/log-command-contract.ts b/src/commands/log-command-contract.ts new file mode 100644 index 000000000..7f909efdb --- /dev/null +++ b/src/commands/log-command-contract.ts @@ -0,0 +1,2 @@ +export const LOG_ACTION_VALUES = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const; +export type LogAction = (typeof LOG_ACTION_VALUES)[number]; diff --git a/src/commands/recording.ts b/src/commands/recording.ts index ae3a96645..e5061ff9f 100644 --- a/src/commands/recording.ts +++ b/src/commands/recording.ts @@ -9,7 +9,7 @@ import type { CommandContext } from '../runtime-contract.ts'; import { AppError } from '../utils/errors.ts'; import { successText } from '../utils/success-text.ts'; import { requireIntInRange } from '../utils/validation.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +import type { BackendResultEnvelope, RuntimeCommand } from './runtime-types.ts'; import { reserveCommandOutput } from './io-policy.ts'; import { toBackendContext } from './selector-read-utils.ts'; @@ -42,9 +42,7 @@ export type RecordingTraceCommandResult = { action: 'start' | 'stop'; outPath?: string; artifact?: ArtifactDescriptor; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export const recordCommand: RuntimeCommand< RecordingRecordCommandOptions, diff --git a/src/commands/runtime-types.ts b/src/commands/runtime-types.ts index 0b1428fad..921ea3667 100644 --- a/src/commands/runtime-types.ts +++ b/src/commands/runtime-types.ts @@ -1,5 +1,6 @@ import type { FileOutputRef } from '../io.ts'; import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; +import type { SessionSurface } from '../core/session-surface.ts'; export type CommandResult = Record; @@ -12,6 +13,15 @@ export type BoundRuntimeCommand, TResult = Co options: TOptions, ) => Promise; +export function toBackendResult(result: unknown): Record | undefined { + return result && typeof result === 'object' ? (result as Record) : undefined; +} + +export type BackendResultEnvelope = { + backendResult?: Record; + message?: string; +}; + export type ScreenshotCommandOptions = CommandContext & { out?: FileOutputRef; fullscreen?: boolean; @@ -20,7 +30,7 @@ export type ScreenshotCommandOptions = CommandContext & { stabilize?: boolean; appId?: string; appBundleId?: string; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + surface?: SessionSurface; }; export type SnapshotCommandOptions = CommandContext & { diff --git a/src/commands/selector-read-shared.ts b/src/commands/selector-read-shared.ts index 1bd3e2b83..76d3cd72b 100644 --- a/src/commands/selector-read-shared.ts +++ b/src/commands/selector-read-shared.ts @@ -8,6 +8,7 @@ import type { SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; import { extractReadableText } from '../utils/text-surface.ts'; import { findNodeByLabel, now, toBackendContext } from './selector-read-utils.ts'; +import type { SelectorSnapshotInput } from './command-input.ts'; export type CapturedSnapshot = { sessionName: string; @@ -15,11 +16,7 @@ export type CapturedSnapshot = { snapshot: SnapshotState; }; -export type SelectorSnapshotOptions = { - depth?: number; - scope?: string; - raw?: boolean; -}; +export type SelectorSnapshotOptions = SelectorSnapshotInput; export async function requireSnapshotSession( runtime: AgentDeviceRuntime, diff --git a/src/commands/system.ts b/src/commands/system.ts index 9ab4ece5a..77b4135bf 100644 --- a/src/commands/system.ts +++ b/src/commands/system.ts @@ -6,32 +6,33 @@ import type { BackendKeyboardResult, } from '../backend.ts'; import type { CommandContext } from '../runtime-contract.ts'; +import type { BackMode } from '../core/back-mode.ts'; import { AppError } from '../utils/errors.ts'; import { successText } from '../utils/success-text.ts'; import { requireIntInRange } from '../utils/validation.ts'; import { isKeyboardAction } from '../utils/keyboard-actions.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +import { + toBackendResult, + type BackendResultEnvelope, + type RuntimeCommand, +} from './runtime-types.ts'; import { toBackendContext } from './selector-read-utils.ts'; import { normalizeOptionalText } from './text.ts'; export type SystemBackCommandOptions = CommandContext & { - mode?: 'in-app' | 'system'; + mode?: BackMode; }; export type SystemBackCommandResult = { kind: 'systemBack'; - mode: 'in-app' | 'system'; - backendResult?: Record; - message?: string; -}; + mode: BackMode; +} & BackendResultEnvelope; export type SystemHomeCommandOptions = CommandContext; export type SystemHomeCommandResult = { kind: 'systemHome'; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type SystemRotateCommandOptions = CommandContext & { orientation: BackendDeviceOrientation; @@ -40,9 +41,7 @@ export type SystemRotateCommandOptions = CommandContext & { export type SystemRotateCommandResult = { kind: 'systemRotated'; orientation: BackendDeviceOrientation; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type SystemKeyboardCommandOptions = CommandContext & { action?: 'status' | 'get' | 'dismiss' | 'enter' | 'return'; @@ -100,9 +99,7 @@ export type SystemSettingsCommandOptions = CommandContext & { export type SystemSettingsCommandResult = { kind: 'settingsOpened'; target?: string; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export type SystemAlertCommandOptions = CommandContext & { action?: BackendAlertAction; @@ -136,9 +133,7 @@ export type SystemAppSwitcherCommandOptions = CommandContext; export type SystemAppSwitcherCommandResult = { kind: 'appSwitcherOpened'; - backendResult?: Record; - message?: string; -}; +} & BackendResultEnvelope; export const backCommand: RuntimeCommand< SystemBackCommandOptions | undefined, @@ -444,7 +439,3 @@ function normalizeAlertHandledResult( function isKeyboardResult(value: unknown): value is BackendKeyboardResult { return Boolean(value && typeof value === 'object'); } - -function toBackendResult(result: unknown): Record | undefined { - return result && typeof result === 'object' ? (result as Record) : undefined; -} diff --git a/src/compat/maestro/interactions.ts b/src/compat/maestro/interactions.ts index fcfaf2c10..f37a8f5b5 100644 --- a/src/compat/maestro/interactions.ts +++ b/src/compat/maestro/interactions.ts @@ -12,8 +12,7 @@ import { import { parseAbsolutePoint, parseMaestroPoint } from './points.ts'; import { MAESTRO_RUNTIME_COMMAND } from './runtime-commands.ts'; import type { MaestroParseContext } from './types.ts'; - -type SwipeDirection = 'up' | 'down' | 'left' | 'right'; +import type { ScrollDirection } from '../../core/scroll-gesture.ts'; export function convertTapOn(value: unknown, context: MaestroParseContext): SessionAction { if (typeof value === 'string') { @@ -269,7 +268,7 @@ function convertCoordinateSwipePoints( ); } -function readMaestroDirection(direction: string, name: string): SwipeDirection { +function readMaestroDirection(direction: string, name: string): ScrollDirection { const normalized = direction.toLowerCase(); switch (normalized) { case 'up': @@ -282,7 +281,7 @@ function readMaestroDirection(direction: string, name: string): SwipeDirection { } } -function readSwipeDirection(direction: string): SwipeDirection { +function readSwipeDirection(direction: string): ScrollDirection { return readMaestroDirection(direction, 'swipe direction'); } diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index 5b1d2d7e3..2545c7e82 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts'; import type { DaemonResponse } from '../../daemon/types.ts'; +import type { DaemonFailureResponse } from '../../daemon/handlers/response.ts'; import type { ReplayVarScope } from '../../replay/vars.ts'; import type { SnapshotState } from '../../utils/snapshot.ts'; import { buildSnapshotDisplayLines } from '../../utils/snapshot-lines.ts'; @@ -52,8 +53,6 @@ type MaestroAssertionRequestParams = MaestroAssertionRuntimeParams & { positionals: string[]; }; -type DaemonFailureResponse = Extract; - export async function invokeMaestroAssertVisible( params: MaestroAssertionRequestParams, ): Promise { diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index e172c94d0..a6ea282ce 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -4,6 +4,7 @@ import { buildSwipeGesturePlan, clampGesturePoint, pointFromPercent, + type GestureReferenceFrame, type ScrollDirection, } from '../../core/scroll-gesture.ts'; import type { ReplayVarScope } from '../../replay/vars.ts'; @@ -271,7 +272,7 @@ async function captureFrameForMaestroScreenSwipe(params: { baseReq: ReplayBaseRequest; invoke: MaestroRuntimeInvoke; scope?: ReplayVarScope; -}): Promise<{ referenceWidth: number; referenceHeight: number } | undefined> { +}): Promise { const snapshotResponse = await captureMaestroSnapshot(params); if (!snapshotResponse.ok) return undefined; const snapshot = readSnapshotState(snapshotResponse.data); @@ -280,7 +281,7 @@ async function captureFrameForMaestroScreenSwipe(params: { function resolveDirectionalScreenSwipe( args: string[], - frame: { referenceWidth: number; referenceHeight: number }, + frame: GestureReferenceFrame, ): MaestroScreenSwipeResolution { const [direction, durationMs] = args; if (!direction) { @@ -306,7 +307,7 @@ function resolveDirectionalScreenSwipe( function buildMaestroDirectionalScreenSwipe( direction: ScrollDirection, - frame: { referenceWidth: number; referenceHeight: number }, + frame: GestureReferenceFrame, durationMs: string | undefined, ): MaestroScreenSwipeResolution { const plan = buildSwipeGesturePlan({ @@ -327,7 +328,7 @@ function buildMaestroDirectionalScreenSwipe( function resolvePercentScreenSwipe( args: string[], - frame: { referenceWidth: number; referenceHeight: number }, + frame: GestureReferenceFrame, platform: string, ): MaestroScreenSwipeResolution { const [startX, startY, endX, endY, durationMs] = args; diff --git a/src/compat/maestro/runtime-support.ts b/src/compat/maestro/runtime-support.ts index c19ce1198..68cb4db5c 100644 --- a/src/compat/maestro/runtime-support.ts +++ b/src/compat/maestro/runtime-support.ts @@ -2,26 +2,19 @@ import { getSnapshotReferenceFrame, type TouchReferenceFrame, } from '../../daemon/touch-reference-frame.ts'; -import type { - DaemonRequest, - DaemonResponse, - DaemonResponseData, - SessionAction, -} from '../../daemon/types.ts'; +import type { DaemonRequest, DaemonResponse, DaemonResponseData } from '../../daemon/types.ts'; +import type { DaemonFailureResponse } from '../../daemon/handlers/response.ts'; +import type { ReplayActionBlockInvoker } from '../../replay/control-flow-runtime.ts'; import type { ReplayVarScope } from '../../replay/vars.ts'; import type { SnapshotState } from '../../utils/snapshot.ts'; export type ReplayBaseRequest = Omit; -export type MaestroReplayInvoker = (params: { - action: SessionAction; - line: number; - step: number; -}) => Promise; +export type MaestroReplayInvoker = ReplayActionBlockInvoker; export type MaestroRuntimeInvoke = (req: DaemonRequest) => Promise; -export type FailedDaemonResponse = Extract; +export type FailedDaemonResponse = DaemonFailureResponse; const maestroReferenceFrameCache = new WeakMap(); const maestroVisibleContextCache = new WeakMap(); diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 1f2489310..759c2b0ec 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -1,4 +1,5 @@ import type { Platform } from '../../utils/device.ts'; +import type { ElementSelectorKey } from '../../core/interactor-types.ts'; import type { Rect, SnapshotNode, SnapshotState } from '../../utils/snapshot.ts'; import { parseSelectorChain } from '../../daemon/selectors.ts'; import { matchesSelector } from '../../daemon/selectors-match.ts'; @@ -334,14 +335,11 @@ function matchesMaestroTerm( return textEqualsOrRegex(value, term.value, options); } -function isMaestroRegexTextKey(key: SelectorTerm['key']): key is 'id' | 'label' | 'text' | 'value' { +function isMaestroRegexTextKey(key: SelectorTerm['key']): key is ElementSelectorKey { return key === 'id' || key === 'label' || key === 'text' || key === 'value'; } -function readMaestroTextTermValue( - node: SnapshotNode, - key: 'id' | 'label' | 'text' | 'value', -): string | undefined { +function readMaestroTextTermValue(node: SnapshotNode, key: ElementSelectorKey): string | undefined { if (key === 'id') return node.identifier; if (key === 'label') return node.label; if (key === 'value') return node.value; diff --git a/src/contracts.ts b/src/contracts.ts index cae5c4efe..86fe985d6 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,5 +1,6 @@ export type { AppErrorCode } from './utils/errors.ts'; export { defaultHintForCode, normalizeError } from './utils/errors.ts'; +import type { PlatformSelector } from './utils/device.ts'; export type SessionRuntimeHints = { platform?: 'ios' | 'android'; @@ -38,6 +39,10 @@ export type DaemonInstallSource = export type DaemonLockPolicy = 'reject' | 'strip'; export type LeaseBackend = 'ios-simulator' | 'ios-instance' | 'android-instance'; +export type DaemonServerMode = 'socket' | 'http' | 'dual'; +export type DaemonTransportPreference = 'auto' | 'socket' | 'http'; +export type SessionIsolationMode = 'none' | 'tenant'; +export type NetworkIncludeMode = 'summary' | 'headers' | 'body' | 'all'; export type DaemonRequestMeta = { requestId?: string; @@ -49,7 +54,7 @@ export type DaemonRequestMeta = { leaseId?: string; leaseTtlMs?: number; leaseBackend?: LeaseBackend; - sessionIsolation?: 'none' | 'tenant'; + sessionIsolation?: SessionIsolationMode; uploadedArtifactId?: string; clientArtifactPaths?: Record; installSource?: DaemonInstallSource; @@ -57,7 +62,7 @@ export type DaemonRequestMeta = { materializedPathRetentionMs?: number; materializationId?: string; lockPolicy?: DaemonLockPolicy; - lockPlatform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; + lockPlatform?: PlatformSelector; requestProgress?: 'replay-test'; }; @@ -357,7 +362,11 @@ export const daemonCommandRequestSchema = schema((input, path) => leaseBackend: optionalEnum( meta, 'leaseBackend', - ['ios-simulator', 'ios-instance', 'android-instance'] as const, + [ + 'ios-simulator', + 'ios-instance', + 'android-instance', + ] as const satisfies readonly LeaseBackend[], `${path}.meta`, ), sessionIsolation: optionalEnum( @@ -395,7 +404,13 @@ export const daemonCommandRequestSchema = schema((input, path) => lockPlatform: optionalEnum( meta, 'lockPlatform', - ['ios', 'macos', 'android', 'linux', 'apple'] as const, + [ + 'ios', + 'macos', + 'android', + 'linux', + 'apple', + ] as const satisfies readonly PlatformSelector[], `${path}.meta`, ), }, diff --git a/src/core/back-mode.ts b/src/core/back-mode.ts new file mode 100644 index 000000000..1391fe25c --- /dev/null +++ b/src/core/back-mode.ts @@ -0,0 +1,2 @@ +export const BACK_MODES = ['in-app', 'system'] as const; +export type BackMode = (typeof BACK_MODES)[number]; diff --git a/src/core/click-button.ts b/src/core/click-button.ts index 870ab0b6a..da35ff079 100644 --- a/src/core/click-button.ts +++ b/src/core/click-button.ts @@ -1,6 +1,7 @@ import { AppError } from '../utils/errors.ts'; -export type ClickButton = 'primary' | 'secondary' | 'middle'; +export const CLICK_BUTTONS = ['primary', 'secondary', 'middle'] as const; +export type ClickButton = (typeof CLICK_BUTTONS)[number]; type ClickButtonFlags = { clickButton?: ClickButton; diff --git a/src/core/device-rotation.ts b/src/core/device-rotation.ts index 2dd5be688..c4cbd1121 100644 --- a/src/core/device-rotation.ts +++ b/src/core/device-rotation.ts @@ -1,10 +1,12 @@ import { AppError } from '../utils/errors.ts'; -export type DeviceRotation = - | 'portrait' - | 'portrait-upside-down' - | 'landscape-left' - | 'landscape-right'; +export const DEVICE_ROTATIONS = [ + 'portrait', + 'portrait-upside-down', + 'landscape-left', + 'landscape-right', +] as const; +export type DeviceRotation = (typeof DEVICE_ROTATIONS)[number]; export function parseDeviceRotation(input: string | undefined): DeviceRotation { if (input === undefined) { diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 6f23139ae..d328e6dc1 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -1,7 +1,10 @@ import type { CliFlags, DaemonExcludedCliFlag } from '../utils/cli-flags.ts'; import type { ScreenshotDispatchFlags } from '../commands/capture-screenshot-options.ts'; import type { DaemonBatchStep } from './batch.ts'; +import type { BackMode } from './back-mode.ts'; import type { ClickButton } from './click-button.ts'; +import type { ElementSelectorKey } from './interactor-types.ts'; +import type { SwipePattern } from './scroll-gesture.ts'; import type { SessionSurface } from './session-surface.ts'; export type MaestroRuntimeFlags = { @@ -49,12 +52,12 @@ export type DispatchContext = ScreenshotDispatchFlags & { pixels?: number; doubleTap?: boolean; clickButton?: ClickButton; - backMode?: 'in-app' | 'system'; + backMode?: BackMode; pauseMs?: number; - pattern?: 'one-way' | 'ping-pong'; + pattern?: SwipePattern; surface?: SessionSurface; directElementSelector?: { - key: 'id' | 'label' | 'text' | 'value'; + key: ElementSelectorKey; value: string; raw: string; allowNonHittableCoordinateFallback?: boolean; diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index 5264538d2..875301a6f 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -7,7 +7,10 @@ import { inferGestureReferenceFrame, parseScrollDirection, parseSwipePreset, + type ScrollDirection, + type SwipePattern, type SwipePreset, + type TransformGestureParams, } from './scroll-gesture.ts'; import { getClickButtonValidationError, @@ -768,8 +771,6 @@ export async function handleTransformGestureCommand( }; } -type GestureDirection = 'up' | 'down' | 'left' | 'right'; - type RotateGestureParams = { degrees: number; x?: number; @@ -777,16 +778,6 @@ type RotateGestureParams = { velocity: number; }; -type TransformGestureParams = { - x: number; - y: number; - dx: number; - dy: number; - scale: number; - degrees: number; - durationMs?: number; -}; - function parseRotateGestureParams(positionals: string[]): RotateGestureParams { const degrees = Number(positionals[0]); if (!Number.isFinite(degrees)) { @@ -842,7 +833,7 @@ function parseOptionalGestureCenter( return { x, y }; } -function parseGestureDirection(input: string | undefined, field: string): GestureDirection { +function parseGestureDirection(input: string | undefined, field: string): ScrollDirection { if (input === 'up' || input === 'down' || input === 'left' || input === 'right') { return input; } @@ -859,7 +850,7 @@ function requireFinitePositiveNumber(value: number, field: string): number { function pointOffsetByDirection( x: number, y: number, - direction: GestureDirection, + direction: ScrollDirection, distance: number, ): { x2: number; y2: number } { switch (direction) { @@ -935,7 +926,7 @@ function formatPressMessage(params: { x: number; y: number; button?: ClickButton return `Tapped (${params.x}, ${params.y})`; } -function formatSwipeMessage(count: number, pattern: 'one-way' | 'ping-pong'): string { +function formatSwipeMessage(count: number, pattern: SwipePattern): string { if (count <= 1) return 'Swiped'; return pattern === 'ping-pong' ? `Swiped ${count} times (ping-pong)` : `Swiped ${count} times`; } diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 2ddf377a8..24c22bcfd 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -1,5 +1,6 @@ +import type { BackMode } from './back-mode.ts'; import type { DeviceRotation } from './device-rotation.ts'; -import type { ScrollDirection } from './scroll-gesture.ts'; +import type { ScrollDirection, TransformGestureParams } from './scroll-gesture.ts'; import type { SettingOptions } from '../platforms/permission-utils.ts'; import type { SessionSurface } from './session-surface.ts'; import type { BackendSnapshotResult } from '../backend.ts'; @@ -17,7 +18,7 @@ export type RunnerContext = { traceLogPath?: string; }; -export type BackMode = 'in-app' | 'system'; +export type { BackMode }; export type ScreenshotOptions = { appBundleId?: string; @@ -26,8 +27,10 @@ export type ScreenshotOptions = { surface?: SessionSurface; }; +export type ElementSelectorKey = 'id' | 'label' | 'text' | 'value'; + export type ElementSelectorTapOptions = { - key: 'id' | 'label' | 'text' | 'value'; + key: ElementSelectorKey; value: string; allowNonHittableCoordinateFallback?: boolean; }; @@ -109,15 +112,7 @@ export type Interactor = { y?: number, velocity?: number, ): Promise | void>; - transformGesture(options: { - x: number; - y: number; - dx: number; - dy: number; - scale: number; - degrees: number; - durationMs?: number; - }): Promise | void>; + transformGesture(options: TransformGestureParams): Promise | void>; appSwitcher(): Promise; readClipboard(): Promise; writeClipboard(text: string): Promise; diff --git a/src/core/scroll-gesture.ts b/src/core/scroll-gesture.ts index b63e42327..aaffba7c0 100644 --- a/src/core/scroll-gesture.ts +++ b/src/core/scroll-gesture.ts @@ -1,8 +1,22 @@ import { AppError } from '../utils/errors.ts'; import type { Rect, SnapshotNode } from '../utils/snapshot.ts'; -export type ScrollDirection = 'up' | 'down' | 'left' | 'right'; -export type SwipePreset = 'left' | 'right' | 'left-edge' | 'right-edge'; +export const SCROLL_DIRECTIONS = ['up', 'down', 'left', 'right'] as const; +export type ScrollDirection = (typeof SCROLL_DIRECTIONS)[number]; +export const SWIPE_PRESETS = ['left', 'right', 'left-edge', 'right-edge'] as const; +export type SwipePreset = (typeof SWIPE_PRESETS)[number]; +export const SWIPE_PATTERNS = ['one-way', 'ping-pong'] as const; +export type SwipePattern = (typeof SWIPE_PATTERNS)[number]; + +export type TransformGestureParams = { + x: number; + y: number; + dx: number; + dy: number; + scale: number; + degrees: number; + durationMs?: number; +}; export type GestureReferenceFrame = { referenceWidth: number; diff --git a/src/core/session-surface.ts b/src/core/session-surface.ts index 0f9429d60..cd57a5899 100644 --- a/src/core/session-surface.ts +++ b/src/core/session-surface.ts @@ -1,13 +1,7 @@ import { AppError } from '../utils/errors.ts'; -export type SessionSurface = 'app' | 'frontmost-app' | 'desktop' | 'menubar'; - -export const SESSION_SURFACES: readonly SessionSurface[] = [ - 'app', - 'frontmost-app', - 'desktop', - 'menubar', -]; +export const SESSION_SURFACES = ['app', 'frontmost-app', 'desktop', 'menubar'] as const; +export type SessionSurface = (typeof SESSION_SURFACES)[number]; export function parseSessionSurface(value: string | undefined): SessionSurface { const normalized = value?.trim().toLowerCase(); diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 0120e4594..52b0c8f08 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -67,7 +67,7 @@ export type OpenAppOptions = { type DaemonInfo = { port?: number; httpPort?: number; - transport?: 'socket' | 'http' | 'dual'; + transport?: DaemonServerMode; token: string; pid: number; version?: string; diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index b8ad7f514..e507508e6 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -1,17 +1,19 @@ import fs from 'node:fs'; import path from 'node:path'; import { readProcessCommand, readProcessStartTime } from '../utils/process-identity.ts'; +import type { NetworkLogBackend } from './network-log.ts'; +import type { ExecResult } from '../utils/exec.ts'; export const APP_LOG_PID_FILENAME = 'app-log.pid'; export type AppLogState = 'active' | 'recovering' | 'failed'; export type AppLogResult = { - backend: 'ios-simulator' | 'ios-device' | 'android' | 'macos'; + backend: NetworkLogBackend; getState: () => AppLogState; startedAt: number; stop: () => Promise; - wait: Promise<{ stdout: string; stderr: string; exitCode: number }>; + wait: Promise; }; type StoredAppLogProcessMeta = { diff --git a/src/daemon/config.ts b/src/daemon/config.ts index 51571f5f7..b69c17c0d 100644 --- a/src/daemon/config.ts +++ b/src/daemon/config.ts @@ -1,9 +1,12 @@ import path from 'node:path'; import { expandUserHomePath, resolveUserPath } from '../utils/path-resolution.ts'; -export type DaemonServerMode = 'socket' | 'http' | 'dual'; -export type DaemonTransportPreference = 'auto' | 'socket' | 'http'; -export type SessionIsolationMode = 'none' | 'tenant'; +import type { + DaemonServerMode, + DaemonTransportPreference, + SessionIsolationMode, +} from '../contracts.ts'; +export type { DaemonServerMode, DaemonTransportPreference, SessionIsolationMode }; export type DaemonPaths = { baseDir: string; diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 888164b5c..bf91a962c 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -1,9 +1,10 @@ import type { SessionState } from './types.ts'; import { tryParseSelectorChain } from './selectors.ts'; import { asAppError } from '../utils/errors.ts'; +import type { ElementSelectorKey } from '../core/interactor-types.ts'; export type DirectIosSelectorTarget = { - key: 'id' | 'label' | 'text' | 'value'; + key: ElementSelectorKey; value: string; raw: string; allowNonHittableCoordinateFallback?: boolean; diff --git a/src/daemon/handlers/interaction-common.ts b/src/daemon/handlers/interaction-common.ts index 046c749f5..62d8bb869 100644 --- a/src/daemon/handlers/interaction-common.ts +++ b/src/daemon/handlers/interaction-common.ts @@ -1,5 +1,6 @@ import type { CommandFlags } from '../../core/dispatch.ts'; import type { SnapshotState } from '../../utils/snapshot.ts'; +import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts'; import type { DaemonCommandContext } from '../context.ts'; import { recordTouchVisualizationEvent } from '../recording-gestures.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -33,7 +34,7 @@ export function buildTouchVisualizationResult(params: { data: Record | undefined; fallbackX: number; fallbackY: number; - referenceFrame?: { referenceWidth: number; referenceHeight: number }; + referenceFrame?: GestureReferenceFrame; extra?: Record; }): Record { const { data, fallbackX, fallbackY, referenceFrame, extra } = params; diff --git a/src/daemon/handlers/interaction-touch-reference-frame.ts b/src/daemon/handlers/interaction-touch-reference-frame.ts index cbccb368c..ff2ea89c3 100644 --- a/src/daemon/handlers/interaction-touch-reference-frame.ts +++ b/src/daemon/handlers/interaction-touch-reference-frame.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '../../core/dispatch.ts'; +import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts'; import type { SnapshotNode } from '../../utils/snapshot.ts'; import { getAndroidScreenSize } from '../../platforms/android/input-actions.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; @@ -14,7 +15,7 @@ async function resolveDirectTouchReferenceFrame(params: { sessionStore: SessionStore; contextFromFlags: ContextFromFlags; captureSnapshotForSession: CaptureSnapshotForSession; -}): Promise<{ referenceWidth: number; referenceHeight: number } | undefined> { +}): Promise { const { session, flags, sessionStore, contextFromFlags, captureSnapshotForSession } = params; if (!session.recording) { return undefined; @@ -63,7 +64,7 @@ export async function resolveDirectTouchReferenceFrameSafely(params: { sessionStore: SessionStore; contextFromFlags: ContextFromFlags; captureSnapshotForSession: CaptureSnapshotForSession; -}): Promise<{ referenceWidth: number; referenceHeight: number } | undefined> { +}): Promise { try { return await resolveDirectTouchReferenceFrame(params); } catch (error) { @@ -81,7 +82,7 @@ export async function resolveDirectTouchReferenceFrameSafely(params: { export function readSnapshotNodesReferenceFrame( nodes: SnapshotNode[], -): { referenceWidth: number; referenceHeight: number } | undefined { +): GestureReferenceFrame | undefined { return getSnapshotReferenceFrame({ nodes, createdAt: 0, diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index af0fbce0e..583c0168b 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -1,4 +1,5 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; +import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts'; import { buttonTag, getClickButtonValidationError, @@ -392,7 +393,7 @@ function readPointFromDirectSelectorTapResult(data: Record): { function readReferenceFrameFromDirectSelectorTapResult( data: Record, -): { referenceWidth: number; referenceHeight: number } | undefined { +): GestureReferenceFrame | undefined { return typeof data.referenceWidth === 'number' && typeof data.referenceHeight === 'number' ? { referenceWidth: data.referenceWidth, referenceHeight: data.referenceHeight } : undefined; diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index ba5e1decb..2b189ca5a 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -17,6 +17,8 @@ import { } from './record-trace-android-chunks.ts'; import { copyAndroidRecordingChunksWithValidation } from './record-trace-android-copy.ts'; +type AndroidRecordingSize = { width: number; height: number }; + const ANDROID_REMOTE_FILE_POLL_MS = 250; const ANDROID_REMOTE_FILE_ATTEMPTS = 20; const ANDROID_REMOTE_FILE_STABLE_POLLS = 4; @@ -150,7 +152,7 @@ function androidRemoteRecordingPaths(timestamp: number, preferredDir?: string): async function resolveAndroidRecordingSize(params: { deviceId: string; quality: number | undefined; -}): Promise<{ width: number; height: number } | undefined> { +}): Promise { const { deviceId, quality } = params; if (quality === undefined || quality >= 10) { return undefined; @@ -180,7 +182,7 @@ function scaledEvenDimension(value: number, quality: number): number { function buildAndroidScreenrecordCommand( remotePath: string, - size: { width: number; height: number } | undefined, + size: AndroidRecordingSize | undefined, ): string { const screenrecordArgs = ['screenrecord']; if (size) { @@ -219,7 +221,7 @@ async function forceStopAndroidProcess(deviceId: string, pid: string): Promise { const { device, recordingBase } = params; - let recordingSize: { width: number; height: number } | undefined; + let recordingSize: AndroidRecordingSize | undefined; try { recordingSize = await resolveAndroidRecordingSize({ deviceId: device.id, diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 4f4c2782a..0ba4a0442 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -20,17 +20,24 @@ import { } from '../app-log.ts'; import { buildPerfFramesResponseData, buildPerfResponseData } from './session-perf.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; +import type { NetworkIncludeMode } from '../../contracts.ts'; +import type { NetworkLogBackend } from '../network-log.ts'; +import { + LOG_ACTION_VALUES as LOG_ACTIONS, + type LogAction as LogsAction, +} from '../../commands/log-command-contract.ts'; -const LOG_ACTIONS = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const; const LOG_ACTIONS_MESSAGE = `logs requires ${LOG_ACTIONS.slice(0, -1).join(', ')}, or ${LOG_ACTIONS.at(-1)}`; const NETWORK_ACTIONS = ['dump', 'log'] as const; const NETWORK_ACTIONS_MESSAGE = `network requires ${NETWORK_ACTIONS.join(' or ')}`; -const NETWORK_INCLUDE_MODES = ['summary', 'headers', 'body', 'all'] as const; +const NETWORK_INCLUDE_MODES = [ + 'summary', + 'headers', + 'body', + 'all', +] as const satisfies readonly NetworkIncludeMode[]; const NETWORK_INCLUDE_MESSAGE = `network include mode must be one of: ${NETWORK_INCLUDE_MODES.join(', ')}`; -type LogsAction = (typeof LOG_ACTIONS)[number]; -type NetworkIncludeMode = (typeof NETWORK_INCLUDE_MODES)[number]; - type ObservabilityParams = { req: DaemonRequest; sessionName: string; @@ -59,9 +66,7 @@ const LOG_ACTION_HANDLERS: Record< handleLogsStop(session, sessionName, sessionStore), }; -function resolveSessionLogBackendLabel( - session: SessionState, -): 'ios-simulator' | 'ios-device' | 'android' | 'macos' { +function resolveSessionLogBackendLabel(session: SessionState): NetworkLogBackend { if (session.appLog) { return session.appLog.backend; } diff --git a/src/daemon/handlers/session-replay-action-runtime.ts b/src/daemon/handlers/session-replay-action-runtime.ts index 2bf769dae..27756f763 100644 --- a/src/daemon/handlers/session-replay-action-runtime.ts +++ b/src/daemon/handlers/session-replay-action-runtime.ts @@ -9,15 +9,14 @@ import type { DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; import { mergeParentFlags } from './handler-utils.ts'; import { invokeMaestroRuntimeCommand } from '../../compat/maestro/runtime.ts'; import { invokeMaestroRunFlowWhenControl } from '../../compat/maestro/runtime-flow.ts'; -import { invokeReplayRetryBlock } from '../../replay/control-flow-runtime.ts'; +import { + invokeReplayRetryBlock, + type ReplayActionBlockInvoker, +} from '../../replay/control-flow-runtime.ts'; type ReplayBaseRequest = Omit; -type ReplayActionInvoker = (params: { - action: SessionAction; - line: number; - step: number; -}) => Promise; +type ReplayActionInvoker = ReplayActionBlockInvoker; export async function invokeReplayAction(params: { req: DaemonRequest; diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index ef4d37bf9..12c54ca7b 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -1,7 +1,7 @@ import http, { type IncomingHttpHeaders } from 'node:http'; import fs from 'node:fs'; import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts'; -import type { JsonRpcId, JsonRpcRequestEnvelope } from '../contracts.ts'; +import type { JsonRpcId, JsonRpcRequestEnvelope, LeaseBackend } from '../contracts.ts'; import type { DaemonInstallSource, DaemonRequest, DaemonResponse } from './types.ts'; import { normalizeTenantId } from './config.ts'; import { @@ -273,11 +273,7 @@ function toLeaseDaemonRequest( runId: readStringParam(params, 'runId'), leaseId: readStringParam(params, 'leaseId'), leaseTtlMs: readIntParam(params, 'ttlMs'), - leaseBackend: readStringParam(params, 'backend') as - | 'ios-simulator' - | 'ios-instance' - | 'android-instance' - | undefined, + leaseBackend: readStringParam(params, 'backend') as LeaseBackend | undefined, }, }; } diff --git a/src/daemon/network-log.ts b/src/daemon/network-log.ts index a20ffe253..b38b8bbb1 100644 --- a/src/daemon/network-log.ts +++ b/src/daemon/network-log.ts @@ -18,7 +18,8 @@ const ANDROID_NEARBY_LINE_RADIUS = 5; const ANDROID_PACKET_SCAN_RADIUS = 12; const NETWORK_LOG_MEMORY_PATH = ''; -export type NetworkIncludeMode = 'summary' | 'headers' | 'body' | 'all'; +import type { NetworkIncludeMode } from '../contracts.ts'; +export type { NetworkIncludeMode }; export type NetworkLogBackend = 'ios-simulator' | 'ios-device' | 'android' | 'macos'; export type NetworkEntry = { diff --git a/src/daemon/record-trace-errors.ts b/src/daemon/record-trace-errors.ts index 40a17ea33..f3c87464e 100644 --- a/src/daemon/record-trace-errors.ts +++ b/src/daemon/record-trace-errors.ts @@ -1,3 +1,5 @@ +import type { ExecResult } from '../utils/exec.ts'; + export function formatRecordTraceError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -13,10 +15,7 @@ type RecordStopFailure = { tooShort: boolean; }; -export function formatRecordTraceExecFailure( - result: { stdout: string; stderr: string; exitCode: number }, - command: string, -): string { +export function formatRecordTraceExecFailure(result: ExecResult, command: string): string { return ( result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.exitCode}` ); diff --git a/src/daemon/recording-gestures.ts b/src/daemon/recording-gestures.ts index e3aef8489..c0ae144c9 100644 --- a/src/daemon/recording-gestures.ts +++ b/src/daemon/recording-gestures.ts @@ -6,7 +6,11 @@ import { resolveTapVisualizationOffsetMs, } from './recording-timing.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; -import { buildScrollGesturePlan } from '../core/scroll-gesture.ts'; +import { + buildScrollGesturePlan, + type ScrollDirection, + type SwipePattern, +} from '../core/scroll-gesture.ts'; import { getSnapshotReferenceFrame, type TouchReferenceFrame as ReferenceFrame, @@ -312,7 +316,7 @@ function buildSwipeEvents( function resolveSwipePathForIndex( index: number, - pattern: 'one-way' | 'ping-pong', + pattern: SwipePattern, x1: number, y1: number, x2: number, @@ -477,7 +481,7 @@ function resolveEventReferenceFrame( return getSnapshotReferenceFrame(snapshot); } -function readDirection(value: unknown): 'up' | 'down' | 'left' | 'right' | undefined { +function readDirection(value: unknown): ScrollDirection | undefined { if (typeof value !== 'string') return undefined; const normalized = value.trim().toLowerCase(); switch (normalized) { @@ -485,7 +489,7 @@ function readDirection(value: unknown): 'up' | 'down' | 'left' | 'right' | undef case 'down': case 'left': case 'right': - return normalized as 'up' | 'down' | 'left' | 'right'; + return normalized as ScrollDirection; default: return undefined; } diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index db8685f0e..fdd7fd85d 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -24,41 +24,27 @@ export type PlatformProviderRequestSession = Pick< 'name' | 'device' | 'appBundleId' | 'appName' | 'surface' >; -export type AndroidAdbProviderResolver = (params: { - req: DaemonRequest; - device: DeviceInfo; - session?: PlatformProviderRequestSession; -}) => AndroidAdbProvider | AndroidAdbExecutor | undefined; +export type PlatformProviderResolver = ( + params: RequestPlatformProviderResolverContext, +) => TResult; -export type AppleRunnerProviderResolver = (params: { - req: DaemonRequest; - device: DeviceInfo; - session?: PlatformProviderRequestSession; -}) => AppleRunnerProvider | AppleRunnerCommandExecutor | undefined; +export type AndroidAdbProviderResolver = PlatformProviderResolver< + AndroidAdbProvider | AndroidAdbExecutor | undefined +>; -export type AppleToolProviderResolver = (params: { - req: DaemonRequest; - device: DeviceInfo; - session?: PlatformProviderRequestSession; -}) => AppleToolProvider | AppleToolCommandExecutor | undefined; +export type AppleRunnerProviderResolver = PlatformProviderResolver< + AppleRunnerProvider | AppleRunnerCommandExecutor | undefined +>; -export type LinuxToolProviderResolver = (params: { - req: DaemonRequest; - device: DeviceInfo; - session?: PlatformProviderRequestSession; -}) => LinuxToolProvider | undefined; +export type AppleToolProviderResolver = PlatformProviderResolver< + AppleToolProvider | AppleToolCommandExecutor | undefined +>; -export type AppLogProviderResolver = (params: { - req: DaemonRequest; - device: DeviceInfo; - session?: PlatformProviderRequestSession; -}) => AppLogProvider | undefined; +export type LinuxToolProviderResolver = PlatformProviderResolver; -export type RecordingProviderResolver = (params: { - req: DaemonRequest; - device: DeviceInfo; - session?: PlatformProviderRequestSession; -}) => RecordingProvider | undefined; +export type AppLogProviderResolver = PlatformProviderResolver; + +export type RecordingProviderResolver = PlatformProviderResolver; export type PlatformProviderResolvers = { androidAdbProvider?: AndroidAdbProviderResolver; diff --git a/src/daemon/touch-reference-frame.ts b/src/daemon/touch-reference-frame.ts index ef64d1c9a..808603664 100644 --- a/src/daemon/touch-reference-frame.ts +++ b/src/daemon/touch-reference-frame.ts @@ -1,10 +1,7 @@ -import { inferGestureReferenceFrame } from '../core/scroll-gesture.ts'; +import { inferGestureReferenceFrame, type GestureReferenceFrame } from '../core/scroll-gesture.ts'; import type { SnapshotState } from '../utils/snapshot.ts'; -export type TouchReferenceFrame = { - referenceWidth: number; - referenceHeight: number; -}; +export type TouchReferenceFrame = GestureReferenceFrame; const snapshotReferenceFrameCache = new WeakMap(); diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 5506d7d16..49a2924e4 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -5,11 +5,14 @@ import type { DaemonResponse as PublicDaemonResponse, DaemonResponseData as PublicDaemonResponseData, DaemonInstallSource as PublicDaemonInstallSource, + DaemonError, LeaseBackend, SessionRuntimeHints as PublicSessionRuntimeHints, } from '../contracts.ts'; export type { DaemonLockPolicy } from '../contracts.ts'; import type { CommandFlags } from '../core/dispatch.ts'; +import type { GestureReferenceFrame, ScrollDirection } from '../core/scroll-gesture.ts'; +import type { NetworkLogBackend } from './network-log.ts'; import type { SessionSurface } from '../core/session-surface.ts'; import type { DeviceInfo, Platform, PlatformSelector } from '../utils/device.ts'; import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; @@ -71,14 +74,7 @@ export type ReplaySuiteTestFailed = { durationMs: number; attempts: number; artifactsDir?: string; - error: { - code: string; - message: string; - hint?: string; - diagnosticId?: string; - logPath?: string; - details?: Record; - }; + error: DaemonError; shardIndex?: number; shardCount?: number; deviceId?: string; @@ -142,7 +138,7 @@ export type RecordingGestureEvent = }) | (RecordingTelemetryTravel & { kind: 'scroll'; - contentDirection: 'up' | 'down' | 'left' | 'right'; + contentDirection: ScrollDirection; amount?: number; pixels?: number; }) @@ -195,10 +191,7 @@ type SessionRecordingBase = { quality?: number; showTouches: boolean; gestureEvents: RecordingGestureEvent[]; - touchReferenceFrame?: { - referenceWidth: number; - referenceHeight: number; - }; + touchReferenceFrame?: GestureReferenceFrame; gestureClockOriginAtMs?: number; gestureClockOriginUptimeMs?: number; runnerSessionId?: string; @@ -272,7 +265,7 @@ export type SessionState = { /** Session-scoped app log stream; logs written to outPath for agent to grep */ appLog?: { platform: Platform; - backend: 'ios-simulator' | 'ios-device' | 'android' | 'macos'; + backend: NetworkLogBackend; outPath: string; startedAt: number; getState: () => AppLogState; diff --git a/src/mcp/router.ts b/src/mcp/router.ts index 659b342dd..db4f51ed0 100644 --- a/src/mcp/router.ts +++ b/src/mcp/router.ts @@ -1,16 +1,11 @@ import { listCommandTools, commandToolExecutor } from './command-tools.ts'; import { readVersion } from '../utils/version.ts'; +import type { JsonRpcId, JsonRpcRequestEnvelope } from '../contracts.ts'; -type JsonRpcId = string | number | null; const MCP_SERVER_NAME = 'agent-device'; const SUPPORTED_PROTOCOL_VERSION = '2025-11-25'; -export type JsonRpcMessage = { - jsonrpc?: string; - id?: JsonRpcId; - method?: string; - params?: unknown; -}; +export type JsonRpcMessage = JsonRpcRequestEnvelope; type JsonRpcResponse = | { jsonrpc: '2.0'; id: JsonRpcId; result: unknown } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2f9c9d3ed..4b5f21c33 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,7 +1,7 @@ import { handleMcpMessage, type JsonRpcMessage } from './router.ts'; +import type { JsonRpcId } from '../contracts.ts'; type JsonRpcResponse = Awaited>>; -type JsonRpcId = string | number | null; type MessageSink = (message: JsonRpcMessage | JsonRpcMessage[]) => void; type PayloadHandler = ( messageOrBatch: JsonRpcMessage | JsonRpcMessage[], diff --git a/src/metro.ts b/src/metro.ts index d11b04a48..7cd2c9d78 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -3,6 +3,7 @@ import { buildMetroRuntimeHints, prepareMetroRuntime, reloadMetro, + type MetroPrepareKind, type ReloadMetroOptions, type ReloadMetroResult, } from './client-metro.ts'; @@ -110,7 +111,7 @@ export type MetroTunnelMessage = MetroTunnelRequestMessage | MetroTunnelResponse export type PrepareRemoteMetroOptions = { projectRoot: string; - kind: 'auto' | 'react-native' | 'expo'; + kind: MetroPrepareKind; publicBaseUrl?: string; proxyBaseUrl?: string; proxyBearerToken?: string; diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index e07831ddc..e0820acff 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -103,8 +103,10 @@ export type AndroidBundleInstaller = ( options: { mode: string }, ) => Promise; +export type AndroidTextInputAction = 'type' | 'fill'; + export type AndroidTextInjectionRequest = { - action: 'type' | 'fill'; + action: AndroidTextInputAction; text: string; delayMs?: number; /** diff --git a/src/platforms/android/input-actions.ts b/src/platforms/android/input-actions.ts index 4a43fd173..6036cde20 100644 --- a/src/platforms/android/input-actions.ts +++ b/src/platforms/android/input-actions.ts @@ -4,7 +4,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DeviceRotation } from '../../core/device-rotation.ts'; import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-gesture.ts'; import { runAndroidAdb, sleep } from './adb.ts'; -import { resolveAndroidTextInjector } from './adb-executor.ts'; +import { resolveAndroidTextInjector, type AndroidTextInputAction } from './adb-executor.ts'; import { getAndroidKeyboardState, type AndroidKeyboardState } from './device-input-state.ts'; import { androidFillFailureDetails, @@ -246,7 +246,7 @@ function resolveAndroidUserRotation(orientation: DeviceRotation): string { async function assertAndroidShellInputIsAppOwned( device: DeviceInfo, - action: 'type' | 'fill', + action: AndroidTextInputAction, ): Promise { let state: AndroidKeyboardState; try { @@ -294,7 +294,7 @@ const ANDROID_INPUT_TEXT_CHUNK_SIZE = 8; async function typeAndroidShell( device: DeviceInfo, - options: { action: 'type' | 'fill'; text: string; chunkSize: number; delayMs: number }, + options: { action: AndroidTextInputAction; text: string; chunkSize: number; delayMs: number }, ): Promise { const parts = options.text.split('\n'); for (const [partIndex, part] of parts.entries()) { @@ -381,7 +381,7 @@ function chunkAndroidInputText(text: string, chunkSize: number): string[] { } function emitAndroidTextDiagnostic( - action: 'type' | 'fill', + action: AndroidTextInputAction, backend: 'provider-native' | 'adb-shell', text: string, ): void { diff --git a/src/platforms/android/multitouch-helper.ts b/src/platforms/android/multitouch-helper.ts index 1e9393178..309733720 100644 --- a/src/platforms/android/multitouch-helper.ts +++ b/src/platforms/android/multitouch-helper.ts @@ -5,6 +5,7 @@ import { AppError, normalizeError } from '../../utils/errors.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { findProjectRoot, readVersion } from '../../utils/version.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import type { TransformGestureParams } from '../../core/scroll-gesture.ts'; import { installAndroidAdbPackage, resolveAndroidAdbExecutor, @@ -98,15 +99,7 @@ export type AndroidRotateGestureOptions = { durationMs?: number; }; -export type AndroidTransformGestureOptions = { - x: number; - y: number; - dx: number; - dy: number; - scale: number; - degrees: number; - durationMs?: number; -}; +export type AndroidTransformGestureOptions = TransformGestureParams; export type AndroidSwipeGestureOptions = { x1: number; diff --git a/src/platforms/android/snapshot-helper-types.ts b/src/platforms/android/snapshot-helper-types.ts index 3ab5dabdf..b1f4667e6 100644 --- a/src/platforms/android/snapshot-helper-types.ts +++ b/src/platforms/android/snapshot-helper-types.ts @@ -3,6 +3,15 @@ import type { AndroidAdbExecutor, AndroidAdbProvider } from './adb-executor.ts'; import type { AndroidSnapshotAnalysis } from './ui-hierarchy.ts'; import type { AndroidSnapshotBackendMetadata } from './snapshot-types.ts'; +export type AndroidSnapshotHelperTransport = 'instrumentation' | 'persistent-session'; +export type AndroidSnapshotCaptureMode = 'interactive-windows' | 'active-window'; +export type AndroidSnapshotHelperInstallReason = + | 'missing' + | 'outdated' + | 'forced' + | 'current' + | 'skipped'; + export const ANDROID_SNAPSHOT_HELPER_NAME = 'android-snapshot-helper'; export const ANDROID_SNAPSHOT_HELPER_PACKAGE = 'com.callstack.agentdevice.snapshothelper'; export const ANDROID_SNAPSHOT_HELPER_RUNNER = @@ -49,7 +58,7 @@ export type AndroidSnapshotHelperInstallResult = { versionCode: number; installedVersionCode?: number; installed: boolean; - reason: 'missing' | 'outdated' | 'forced' | 'current' | 'skipped'; + reason: AndroidSnapshotHelperInstallReason; }; export type AndroidSnapshotHelperCaptureOptions = { @@ -79,12 +88,12 @@ export type AndroidSnapshotHelperMetadata = { maxDepth?: number; maxNodes?: number; rootPresent?: boolean; - captureMode?: 'interactive-windows' | 'active-window'; + captureMode?: AndroidSnapshotCaptureMode; windowCount?: number; nodeCount?: number; truncated?: boolean; elapsedMs?: number; - transport?: 'instrumentation' | 'persistent-session'; + transport?: AndroidSnapshotHelperTransport; sessionReused?: boolean; }; diff --git a/src/platforms/android/snapshot-types.ts b/src/platforms/android/snapshot-types.ts index 38da2dbed..b25870e28 100644 --- a/src/platforms/android/snapshot-types.ts +++ b/src/platforms/android/snapshot-types.ts @@ -1,18 +1,24 @@ +import type { + AndroidSnapshotCaptureMode, + AndroidSnapshotHelperInstallReason, + AndroidSnapshotHelperTransport, +} from './snapshot-helper-types.ts'; + export type AndroidSnapshotBackendMetadata = { backend: 'android-helper' | 'uiautomator-dump'; helperVersion?: string; helperApiVersion?: string; - helperTransport?: 'instrumentation' | 'persistent-session'; + helperTransport?: AndroidSnapshotHelperTransport; helperSessionReused?: boolean; fallbackReason?: string; - installReason?: 'missing' | 'outdated' | 'forced' | 'current' | 'skipped'; + installReason?: AndroidSnapshotHelperInstallReason; waitForIdleTimeoutMs?: number; waitForIdleQuietMs?: number; timeoutMs?: number; maxDepth?: number; maxNodes?: number; rootPresent?: boolean; - captureMode?: 'interactive-windows' | 'active-window'; + captureMode?: AndroidSnapshotCaptureMode; windowCount?: number; nodeCount?: number; helperTruncated?: boolean; diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index c2168446b..1a1583c9d 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -2,6 +2,8 @@ import crypto from 'node:crypto'; import { AppError } from '../../utils/errors.ts'; import type { ClickButton } from '../../core/click-button.ts'; import type { DeviceRotation } from '../../core/device-rotation.ts'; +import type { ScrollDirection, SwipePattern } from '../../core/scroll-gesture.ts'; +import type { ElementSelectorKey } from '../../core/interactor-types.ts'; import { createRequestCanceledError, isRequestCanceled } from '../../daemon/request-cancel.ts'; import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; import type { RunnerSession } from './runner-session-types.ts'; @@ -47,7 +49,7 @@ export type RunnerCommand = { statusCommandId?: string; appBundleId?: string; text?: string; - selectorKey?: 'id' | 'label' | 'text' | 'value'; + selectorKey?: ElementSelectorKey; selectorValue?: string; allowNonHittableCoordinateFallback?: boolean; delayMs?: number; @@ -61,13 +63,13 @@ export type RunnerCommand = { intervalMs?: number; doubleTap?: boolean; pauseMs?: number; - pattern?: 'one-way' | 'ping-pong'; + pattern?: SwipePattern; x2?: number; y2?: number; dx?: number; dy?: number; durationMs?: number; - direction?: 'up' | 'down' | 'left' | 'right'; + direction?: ScrollDirection; orientation?: DeviceRotation; scale?: number; degrees?: number; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index dc62a31fe..493c485a7 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -4,6 +4,7 @@ import { withKeyedLock } from '../../utils/keyed-lock.ts'; import { Deadline } from '../../utils/retry.ts'; import { isProcessAlive, isProcessGroupAlive } from '../../utils/process-identity.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import type { AppleRunnerLifecycleOptions } from './runner-provider.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; @@ -46,16 +47,7 @@ import type { RunnerSession } from './runner-session-types.ts'; export type { RunnerSession } from './runner-session-types.ts'; -export type RunnerSessionOptions = { - verbose?: boolean; - logPath?: string; - traceLogPath?: string; - cleanStaleBundles?: boolean; - startupTimeoutMs?: number; - requestId?: string; - buildTimeoutMs?: number; - forceRunnerXctestrunRebuild?: boolean; -}; +export type RunnerSessionOptions = AppleRunnerLifecycleOptions; const runnerSessions = new Map(); const runnerSessionLocks = new Map>(); diff --git a/src/platforms/linux/atspi-bridge.ts b/src/platforms/linux/atspi-bridge.ts index 5ddbb2c6d..185521f55 100644 --- a/src/platforms/linux/atspi-bridge.ts +++ b/src/platforms/linux/atspi-bridge.ts @@ -12,7 +12,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { AppError } from '../../utils/errors.ts'; -import type { RawSnapshotNode } from '../../utils/snapshot.ts'; +import type { RawSnapshotNode, Rect } from '../../utils/snapshot.ts'; import { normalizeAtspiRole } from './role-map.ts'; import { resolveLinuxToolProvider, runLinuxToolCommand } from './tool-provider.ts'; import type { @@ -72,7 +72,7 @@ type PythonNode = { role: string; label?: string; value?: string; - rect?: { x: number; y: number; width: number; height: number }; + rect?: Rect; enabled?: boolean; selected?: boolean; hittable?: boolean; diff --git a/src/platforms/linux/tool-provider.ts b/src/platforms/linux/tool-provider.ts index b208e6e84..d8dadb4e1 100644 --- a/src/platforms/linux/tool-provider.ts +++ b/src/platforms/linux/tool-provider.ts @@ -3,6 +3,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { AppError } from '../../utils/errors.ts'; import { createScopedProvider } from '../../utils/scoped-provider.ts'; import { sleep } from '../../utils/timeouts.ts'; +import type { ClickButton } from '../../core/click-button.ts'; import type { LinuxAccessibilityTree, LinuxSnapshotSurface, @@ -45,7 +46,7 @@ export type LinuxAccessibilityProvider = { ): Promise; }; -export type LinuxPointerButton = 'primary' | 'secondary' | 'middle'; +export type LinuxPointerButton = ClickButton; export type LinuxInputProvider = { click(x: number, y: number, button: LinuxPointerButton): Promise; diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index 6c221adc6..7e5b8a741 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -1,8 +1,16 @@ import { buildPrimaryEnvVarName } from './utils/source-value.ts'; +import type { + DaemonServerMode, + DaemonTransportPreference, + LeaseBackend, + SessionIsolationMode, +} from './contracts.ts'; +import type { DeviceTarget, PlatformSelector } from './utils/device.ts'; +import type { MetroPrepareKind } from './client-metro.ts'; export type RemoteConfigMetroOptions = { metroProjectRoot?: string; - metroKind?: 'auto' | 'react-native' | 'expo'; + metroKind?: MetroPrepareKind; metroPublicBaseUrl?: string; metroProxyBaseUrl?: string; metroBearerToken?: string; @@ -20,15 +28,15 @@ export type RemoteConfigProfile = RemoteConfigMetroOptions & { stateDir?: string; daemonBaseUrl?: string; daemonAuthToken?: string; - daemonTransport?: 'auto' | 'socket' | 'http'; - daemonServerMode?: 'socket' | 'http' | 'dual'; + daemonTransport?: DaemonTransportPreference; + daemonServerMode?: DaemonServerMode; tenant?: string; - sessionIsolation?: 'none' | 'tenant'; + sessionIsolation?: SessionIsolationMode; runId?: string; leaseId?: string; - leaseBackend?: 'ios-simulator' | 'ios-instance' | 'android-instance'; - platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; - target?: 'mobile' | 'tv' | 'desktop'; + leaseBackend?: LeaseBackend; + platform?: PlatformSelector; + target?: DeviceTarget; device?: string; udid?: string; serial?: string; diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 803313934..35b740555 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -1,5 +1,16 @@ -import { SESSION_SURFACES } from '../core/session-surface.ts'; -import type { DaemonInstallSource } from '../contracts.ts'; +import { SESSION_SURFACES, type SessionSurface } from '../core/session-surface.ts'; +import type { BackMode } from '../core/back-mode.ts'; +import type { ClickButton } from '../core/click-button.ts'; +import type { SwipePattern } from '../core/scroll-gesture.ts'; +import type { DeviceTarget, PlatformSelector } from './device.ts'; +import type { + DaemonInstallSource, + DaemonServerMode, + DaemonTransportPreference, + LeaseBackend, + NetworkIncludeMode, + SessionIsolationMode, +} from '../contracts.ts'; import type { RemoteConfigMetroOptions } from '../remote-config-schema.ts'; import { SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, @@ -18,20 +29,20 @@ export type CliFlags = RemoteConfigMetroOptions & stateDir?: string; daemonBaseUrl?: string; daemonAuthToken?: string; - daemonTransport?: 'auto' | 'socket' | 'http'; - daemonServerMode?: 'socket' | 'http' | 'dual'; + daemonTransport?: DaemonTransportPreference; + daemonServerMode?: DaemonServerMode; tenant?: string; - sessionIsolation?: 'none' | 'tenant'; + sessionIsolation?: SessionIsolationMode; runId?: string; leaseId?: string; - leaseBackend?: 'ios-simulator' | 'ios-instance' | 'android-instance'; + leaseBackend?: LeaseBackend; force?: boolean; noLogin?: boolean; sessionLock?: 'reject' | 'strip'; sessionLocked?: boolean; sessionLockConflicts?: 'reject' | 'strip'; - platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; - target?: 'mobile' | 'tv' | 'desktop'; + platform?: PlatformSelector; + target?: DeviceTarget; device?: string; udid?: string; serial?: string; @@ -50,7 +61,7 @@ export type CliFlags = RemoteConfigMetroOptions & snapshotScope?: string; snapshotRaw?: boolean; snapshotForceFull?: boolean; - networkInclude?: 'summary' | 'headers' | 'body' | 'all'; + networkInclude?: NetworkIncludeMode; baseline?: string; threshold?: string; appsFilter?: 'user-installed' | 'all'; @@ -64,10 +75,10 @@ export type CliFlags = RemoteConfigMetroOptions & jitterPx?: number; pixels?: number; doubleTap?: boolean; - clickButton?: 'primary' | 'secondary' | 'middle'; - backMode?: 'in-app' | 'system'; + clickButton?: ClickButton; + backMode?: BackMode; pauseMs?: number; - pattern?: 'one-way' | 'ping-pong'; + pattern?: SwipePattern; activity?: string; launchConsole?: string; launchArgs?: string[]; @@ -77,7 +88,7 @@ export type CliFlags = RemoteConfigMetroOptions & saveScript?: boolean | string; shutdown?: boolean; relaunch?: boolean; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + surface?: SessionSurface; headless?: boolean; restart?: boolean; noRecord?: boolean; diff --git a/src/utils/device.ts b/src/utils/device.ts index 60c7041ca..a7e15f0ee 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -4,7 +4,8 @@ export type ApplePlatform = 'ios' | 'macos'; export type Platform = ApplePlatform | 'android' | 'linux'; export type PlatformSelector = Platform | 'apple'; export type DeviceKind = 'simulator' | 'emulator' | 'device'; -export type DeviceTarget = 'mobile' | 'tv' | 'desktop'; +export const DEVICE_TARGETS = ['mobile', 'tv', 'desktop'] as const; +export type DeviceTarget = (typeof DEVICE_TARGETS)[number]; export type DeviceInfo = { platform: Platform; diff --git a/src/utils/output.ts b/src/utils/output.ts index 6e0800b6e..45d166b08 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -6,7 +6,8 @@ import { import { AppError, normalizeError, type NormalizedError } from './errors.ts'; import { detectPossibleRepeatedNavSubtree } from './repeated-nav-subtree.ts'; import { buildSnapshotDisplayLines, formatSnapshotLine } from './snapshot-lines.ts'; -import type { SnapshotNode, SnapshotUnchanged, SnapshotVisibility } from './snapshot.ts'; +import type { Rect, SnapshotNode, SnapshotUnchanged, SnapshotVisibility } from './snapshot.ts'; +import type { MovementRange } from './screenshot-diff-ocr.ts'; import type { ScreenshotDiffResult } from './screenshot-diff.ts'; import type { ScreenshotDiffRegion } from './screenshot-diff-regions.ts'; import { styleText } from 'node:util'; @@ -16,14 +17,7 @@ type JsonResult = | { success: true; data?: unknown } | { success: false; - error: { - code: string; - message: string; - hint?: string; - diagnosticId?: string; - logPath?: string; - details?: Record; - }; + error: NormalizedError; }; export function printJson(result: JsonResult): void { @@ -487,7 +481,7 @@ function formatScreenshotDiffNonTextLines(data: ScreenshotDiffResult, useColor: return lines; } -function formatRect(rect: { x: number; y: number; width: number; height: number }): string { +function formatRect(rect: Rect): string { return `x=${rect.x},y=${rect.y},w=${rect.width},h=${rect.height}`; } @@ -532,7 +526,7 @@ function formatNonTextHint(delta: { return `${delta.likelyKind}${anchor}${region}`; } -function formatRange(range: { min: number; max: number }): string { +function formatRange(range: MovementRange): string { return range.min === range.max ? formatSignedPixels(range.min) : `${formatSignedPixels(range.min)}..${formatSignedPixels(range.max)}`; diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index b1c290f24..4a0168dcd 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -1,6 +1,6 @@ import { AppError } from './errors.ts'; import type { DeviceKind, DeviceTarget, Platform } from './device.ts'; -import type { Point } from './snapshot.ts'; +import type { Point, Rect } from './snapshot.ts'; function readRequired( record: Record, @@ -71,10 +71,7 @@ export function readDeviceTarget(record: Record, key: string): return readOptional(record, key, parseDeviceTarget) ?? 'mobile'; } -export function readRect( - record: Record, - key: string, -): { x: number; y: number; width: number; height: number } | undefined { +export function readRect(record: Record, key: string): Rect | undefined { const value = record[key]; if (!isRecord(value)) return undefined; const x = readNumberField(value, 'x'); diff --git a/src/utils/screenshot-diff-non-text.ts b/src/utils/screenshot-diff-non-text.ts index 33ae3f49e..529c1f03a 100644 --- a/src/utils/screenshot-diff-non-text.ts +++ b/src/utils/screenshot-diff-non-text.ts @@ -9,6 +9,7 @@ import { rectCenter, squaredDistance, unionRects, + type ImageDimensions, } from './screenshot-geometry.ts'; export type ScreenshotNonTextDelta = { @@ -251,7 +252,7 @@ function classifyLikelyKind( rect: Rect, slot: ScreenshotNonTextDelta['slot'], differentPixels: number, - image: { width: number; height: number }, + image: ImageDimensions, ): NonTextKind { const aspect = rect.width / rect.height; const density = differentPixels / (rect.width * rect.height); @@ -293,7 +294,7 @@ function scoreNonTextDelta( rect: Rect; }, differentPixels: number, - image: { width: number; height: number }, + image: ImageDimensions, ): number { const sizePenalty = isLargeResidual(delta.rect, image) ? LARGE_RESIDUAL_SCORE_PENALTY : 0; const regionScore = delta.regionIndex ? REGION_OVERLAP_SCORE : 0; @@ -306,7 +307,7 @@ function scoreNonTextDelta( ); } -function isLargeResidual(rect: Rect, image: { width: number; height: number }): boolean { +function isLargeResidual(rect: Rect, image: ImageDimensions): boolean { return ( rect.width >= image.width * LARGE_RESIDUAL_WIDTH_RATIO || rect.height >= image.height * LARGE_RESIDUAL_HEIGHT_RATIO diff --git a/src/utils/screenshot-diff-ocr.ts b/src/utils/screenshot-diff-ocr.ts index 428a272f6..729f3b557 100644 --- a/src/utils/screenshot-diff-ocr.ts +++ b/src/utils/screenshot-diff-ocr.ts @@ -1,5 +1,7 @@ import type { Rect } from './snapshot.ts'; import { runCmd, whichCmd } from './exec.ts'; + +export type MovementRange = { min: number; max: number }; import { rectCenter, squaredDistance, unionRects } from './screenshot-geometry.ts'; export type ScreenshotOcrBlock = { @@ -13,15 +15,15 @@ export type ScreenshotOcrTextMatch = { text: string; baselineRect: Rect; currentRect: Rect; - delta: { x: number; y: number; width: number; height: number }; + delta: Rect; confidence: number; possibleTextMetricMismatch: boolean; }; export type ScreenshotOcrMovementCluster = { texts: string[]; - xRange: { min: number; max: number }; - yRange: { min: number; max: number }; + xRange: MovementRange; + yRange: MovementRange; }; export type ScreenshotOcrSummary = { diff --git a/src/utils/screenshot-diff-regions.ts b/src/utils/screenshot-diff-regions.ts index acbf97b7a..9986dcf6c 100644 --- a/src/utils/screenshot-diff-regions.ts +++ b/src/utils/screenshot-diff-regions.ts @@ -1,4 +1,5 @@ import type { PNG } from './png.ts'; +import type { Rect } from './snapshot.ts'; import { findConnectedMaskComponents } from './screenshot-diff-components.ts'; import { splitLargeDiffRegions } from './screenshot-diff-region-split.ts'; import type { MutableDiffRegion } from './screenshot-diff-region-types.ts'; @@ -13,8 +14,8 @@ type ScreenshotDiffColor = { export type ScreenshotDiffRegion = { index: number; - rect: { x: number; y: number; width: number; height: number }; - normalizedRect: { x: number; y: number; width: number; height: number }; + rect: Rect; + normalizedRect: Rect; differentPixels: number; shareOfDiffPercentage: number; densityPercentage: number; @@ -33,7 +34,7 @@ export type ScreenshotDiffRegionOverlayMatch = { ref: string; label?: string; regionCoveragePercentage: number; - rect: { x: number; y: number; width: number; height: number }; + rect: Rect; }; const DEFAULT_MAX_DIFF_REGIONS = 8; diff --git a/src/utils/screenshot-diff.ts b/src/utils/screenshot-diff.ts index 7e0ff0bd4..02f7d62d6 100644 --- a/src/utils/screenshot-diff.ts +++ b/src/utils/screenshot-diff.ts @@ -9,10 +9,11 @@ import { } from './screenshot-diff-non-text.ts'; import { summarizeScreenshotOcr, type ScreenshotOcrSummary } from './screenshot-diff-ocr.ts'; import { summarizeDiffRegions, type ScreenshotDiffRegion } from './screenshot-diff-regions.ts'; +import type { ImageDimensions } from './screenshot-geometry.ts'; export type ScreenshotDimensionMismatch = { - expected: { width: number; height: number }; - actual: { width: number; height: number }; + expected: ImageDimensions; + actual: ImageDimensions; }; export type ScreenshotDiffResult = { diff --git a/src/utils/screenshot-geometry.ts b/src/utils/screenshot-geometry.ts index ecd282475..3baad93d7 100644 --- a/src/utils/screenshot-geometry.ts +++ b/src/utils/screenshot-geometry.ts @@ -1,5 +1,7 @@ import type { Rect } from './snapshot.ts'; +export type ImageDimensions = { width: number; height: number }; + export function unionRects(rects: Rect[]): Rect { let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; diff --git a/src/utils/scroll-edge-state.ts b/src/utils/scroll-edge-state.ts index e73ba7e76..6b4f1bc02 100644 --- a/src/utils/scroll-edge-state.ts +++ b/src/utils/scroll-edge-state.ts @@ -4,6 +4,7 @@ import { } from './mobile-snapshot-semantics.ts'; import { AppError } from './errors.ts'; import { isScrollableNodeLike } from './scrollable.ts'; +import type { ScrollDirection } from '../core/scroll-gesture.ts'; import type { HiddenContentHint, Point, RawSnapshotNode, SnapshotNode } from './snapshot.ts'; export type ScrollEdge = 'top' | 'bottom'; @@ -111,7 +112,7 @@ export async function runScrollEdgePasses(params: { } export function formatScrollEdgeMessage( - direction: 'up' | 'down' | 'left' | 'right', + direction: ScrollDirection, edge: ScrollEdge | undefined, passes: number, amount: number | undefined,