From e8770076bd1e7c0e70841e323614e755f479c41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 18:59:21 +0200 Subject: [PATCH 1/5] refactor(types): rename NetworkLogBackend -> LogBackend The union is reused for both network-log and app-log backends, so the network-specific name was misleading. Also renames the resolveNetworkLogBackend helper to resolveLogBackend. No behavior change. --- src/daemon/app-log-process.ts | 4 ++-- src/daemon/app-log.ts | 8 ++++---- src/daemon/handlers/session-observability.ts | 4 ++-- src/daemon/network-log.ts | 8 ++++---- src/daemon/types.ts | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index e507508e6..c9686d0de 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -1,7 +1,7 @@ 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 { LogBackend } from './network-log.ts'; import type { ExecResult } from '../utils/exec.ts'; export const APP_LOG_PID_FILENAME = 'app-log.pid'; @@ -9,7 +9,7 @@ export const APP_LOG_PID_FILENAME = 'app-log.pid'; export type AppLogState = 'active' | 'recovering' | 'failed'; export type AppLogResult = { - backend: NetworkLogBackend; + backend: LogBackend; getState: () => AppLogState; startedAt: number; stop: () => Promise; diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index 2229d9783..205f7cd93 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -27,7 +27,7 @@ import { readRecentNetworkTrafficFromText, type NetworkDump, type NetworkIncludeMode, - type NetworkLogBackend, + type LogBackend, } from './network-log.ts'; export type { AppLogResult } from './app-log-process.ts'; @@ -49,7 +49,7 @@ export type AppLogDoctorResult = { }; export type SessionNetworkCapture = { - backend: NetworkLogBackend; + backend: LogBackend; dump: NetworkDump; notes: string[]; }; @@ -176,7 +176,7 @@ export function getAppLogPathMetadata(outPath: string): { }; } -function resolveNetworkLogBackend(device: DeviceInfo): NetworkLogBackend { +function resolveLogBackend(device: DeviceInfo): LogBackend { if (device.platform === 'macos') return 'macos'; if (device.platform === 'ios') { return device.kind === 'device' ? 'ios-device' : 'ios-simulator'; @@ -206,7 +206,7 @@ export async function readSessionNetworkCapture(params: { maxPayloadChars, maxScanLines, } = params; - const backend = resolveNetworkLogBackend(device); + const backend = resolveLogBackend(device); let dump = readRecentNetworkTraffic(appLogPath, { backend, maxEntries, diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 0ba4a0442..1fbb1a275 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -21,7 +21,7 @@ import { 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 type { LogBackend } from '../network-log.ts'; import { LOG_ACTION_VALUES as LOG_ACTIONS, type LogAction as LogsAction, @@ -66,7 +66,7 @@ const LOG_ACTION_HANDLERS: Record< handleLogsStop(session, sessionName, sessionStore), }; -function resolveSessionLogBackendLabel(session: SessionState): NetworkLogBackend { +function resolveSessionLogBackendLabel(session: SessionState): LogBackend { if (session.appLog) { return session.appLog.backend; } diff --git a/src/daemon/network-log.ts b/src/daemon/network-log.ts index b38b8bbb1..f15421d26 100644 --- a/src/daemon/network-log.ts +++ b/src/daemon/network-log.ts @@ -20,7 +20,7 @@ const NETWORK_LOG_MEMORY_PATH = ''; import type { NetworkIncludeMode } from '../contracts.ts'; export type { NetworkIncludeMode }; -export type NetworkLogBackend = 'ios-simulator' | 'ios-device' | 'android' | 'macos'; +export type LogBackend = 'ios-simulator' | 'ios-device' | 'android' | 'macos'; export type NetworkEntry = { method?: string; @@ -74,7 +74,7 @@ export function mergeNetworkDumps( export function readRecentNetworkTraffic( logPath: string, options?: { - backend?: NetworkLogBackend; + backend?: LogBackend; maxEntries?: number; include?: NetworkIncludeMode; maxPayloadChars?: number; @@ -108,7 +108,7 @@ export function readRecentNetworkTrafficFromText( content: string, options?: { path?: string; - backend?: NetworkLogBackend; + backend?: LogBackend; maxEntries?: number; include?: NetworkIncludeMode; maxPayloadChars?: number; @@ -156,7 +156,7 @@ function parseNetworkLine( lines: string[], lineIndex: number, lineNumber: number, - backend: NetworkLogBackend | undefined, + backend: LogBackend | undefined, include: NetworkIncludeMode, maxPayloadChars: number, ): NetworkEntry | null { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 49a2924e4..18fe3ed3a 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -12,7 +12,7 @@ import type { 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 { LogBackend } 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'; @@ -265,7 +265,7 @@ export type SessionState = { /** Session-scoped app log stream; logs written to outPath for agent to grep */ appLog?: { platform: Platform; - backend: NetworkLogBackend; + backend: LogBackend; outPath: string; startedAt: number; getState: () => AppLogState; From 7ee4d6bd6dbcb5f02cac67947f3d72eb2459ff9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:04:41 +0200 Subject: [PATCH 2/5] refactor(types): add string-enum helpers (isStringMember/parseStringMember) Small utils/string-enum.ts helpers for the now-common 'as const tuple' string enums: a membership guard and a strict exact-match parser. Adopt at the strict sites (parseSessionSurface, parseScrollDirection, parseSwipePreset, isPerfArea, isPerfAction), replacing hand-rolled switch/includes boilerplate. Alias-accepting parsers (device rotation) keep their custom logic. Error messages unchanged. Chose focused helpers over a defineStringEnum factory: the modules are already concise with tuple-as-source, and a factory would force export-name churn for no gain. --- src/commands/perf-command-contract.ts | 6 +++-- src/core/scroll-gesture.ts | 28 ++++++--------------- src/core/session-surface.ts | 19 ++++----------- src/utils/string-enum.ts | 35 +++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 src/utils/string-enum.ts diff --git a/src/commands/perf-command-contract.ts b/src/commands/perf-command-contract.ts index e332aa07e..63a4b7e14 100644 --- a/src/commands/perf-command-contract.ts +++ b/src/commands/perf-command-contract.ts @@ -1,3 +1,5 @@ +import { isStringMember } from '../utils/string-enum.ts'; + export const PERF_AREA_VALUES = ['metrics', 'frames'] as const; export const PERF_ACTION_VALUES = ['sample'] as const; @@ -8,9 +10,9 @@ export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics or frames'; export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample'; export function isPerfArea(value: string): value is PerfArea { - return (PERF_AREA_VALUES as readonly string[]).includes(value); + return isStringMember(PERF_AREA_VALUES, value); } export function isPerfAction(value: string): value is PerfAction { - return (PERF_ACTION_VALUES as readonly string[]).includes(value); + return isStringMember(PERF_ACTION_VALUES, value); } diff --git a/src/core/scroll-gesture.ts b/src/core/scroll-gesture.ts index aaffba7c0..8ba6beef5 100644 --- a/src/core/scroll-gesture.ts +++ b/src/core/scroll-gesture.ts @@ -1,4 +1,5 @@ import { AppError } from '../utils/errors.ts'; +import { parseStringMember } from '../utils/string-enum.ts'; import type { Rect, SnapshotNode } from '../utils/snapshot.ts'; export const SCROLL_DIRECTIONS = ['up', 'down', 'left', 'right'] as const; @@ -149,18 +150,9 @@ export function buildSwipePresetGesturePlan( } export function parseSwipePreset(input: string | undefined): SwipePreset { - switch (input) { - case 'left': - case 'right': - case 'left-edge': - case 'right-edge': - return input; - default: - throw new AppError( - 'INVALID_ARGS', - 'gesture swipe requires left, right, left-edge, or right-edge', - ); - } + return parseStringMember(SWIPE_PRESETS, input, { + message: 'gesture swipe requires left, right, left-edge, or right-edge', + }); } export function inferGestureReferenceFrame( @@ -200,15 +192,9 @@ export function clampGesturePoint( } export function parseScrollDirection(direction: string): ScrollDirection { - switch (direction) { - case 'up': - case 'down': - case 'left': - case 'right': - return direction; - default: - throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`); - } + return parseStringMember(SCROLL_DIRECTIONS, direction, { + message: `Unknown direction: ${direction}`, + }); } function scrollDirectionForFingerSwipe(direction: ScrollDirection): ScrollDirection { diff --git a/src/core/session-surface.ts b/src/core/session-surface.ts index cd57a5899..711b48da3 100644 --- a/src/core/session-surface.ts +++ b/src/core/session-surface.ts @@ -1,20 +1,11 @@ -import { AppError } from '../utils/errors.ts'; +import { parseStringMember } from '../utils/string-enum.ts'; 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(); - if ( - normalized === 'app' || - normalized === 'frontmost-app' || - normalized === 'desktop' || - normalized === 'menubar' - ) { - return normalized; - } - throw new AppError( - 'INVALID_ARGS', - `Invalid surface: ${value}. Use ${SESSION_SURFACES.join('|')}.`, - ); + return parseStringMember(SESSION_SURFACES, value, { + normalize: (raw) => raw.trim().toLowerCase(), + message: `Invalid surface: ${value}. Use ${SESSION_SURFACES.join('|')}.`, + }); } diff --git a/src/utils/string-enum.ts b/src/utils/string-enum.ts new file mode 100644 index 000000000..ef5159467 --- /dev/null +++ b/src/utils/string-enum.ts @@ -0,0 +1,35 @@ +import { AppError } from './errors.ts'; + +/** + * Membership guard for an `as const` string tuple (the single source of truth for + * a string-literal union). Narrows `value` to the tuple's element union. + */ +export function isStringMember( + values: T, + value: string, +): value is T[number] { + return (values as readonly string[]).includes(value); +} + +/** + * Strict (exact-match) parse for an `as const` string tuple. Throws an + * `INVALID_ARGS` AppError when `value` is not a member. Pass `normalize` to + * trim/lowercase before matching, and `message` for a custom error. + * + * Note: only for strict vocabularies — parsers that accept aliases (e.g. + * device rotation `left` -> `landscape-left`) keep their own logic. + */ +export function parseStringMember( + values: T, + value: string | undefined, + options: { normalize?: (raw: string) => string; message?: string } = {}, +): T[number] { + const normalized = value === undefined ? undefined : (options.normalize?.(value) ?? value); + if (normalized !== undefined && isStringMember(values, normalized)) { + return normalized; + } + throw new AppError( + 'INVALID_ARGS', + options.message ?? `Invalid value: ${value}. Use ${values.join('|')}.`, + ); +} From 854502b069f1d452bbd6c82476e4932c9bc23523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:11:30 +0200 Subject: [PATCH 3/5] refactor(types): dedupe runner/interaction tail shapes - RunnerXctestrunCacheKind / RunnerXctestrunArtifactState shared between RunnerXctestrunArtifact and AppleRunnerPrepareResult. - Annotate buildInteractionSurfaceSignature with its existing InteractionSurfaceSignature alias. - RunnerOpts / runnerOptionsFromContext use Pick. (Skipped daemon-invoke-fn: maestro already has its own MaestroRuntimeInvoke alias, so forcing a shared DaemonInvoke creates naming inconsistency for ~2 lines.) --- src/core/dispatch-interactions.ts | 11 ++++------- src/daemon/interaction-outcome-policy.ts | 10 +++------- src/platforms/ios/interactions.ts | 7 +------ src/platforms/ios/runner-provider.ts | 5 +++-- src/platforms/ios/runner-xctestrun.ts | 7 +++++-- 5 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index a2982b396..a461cf5c5 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -32,7 +32,7 @@ import { runRepeatedSeries, } from './dispatch-series.ts'; import type { DispatchContext } from './dispatch-context.ts'; -import type { Interactor } from './interactor-types.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; export async function handleLongPressCommand( interactor: Interactor, @@ -386,12 +386,9 @@ async function runDirectPressSeries( ); } -function runnerOptionsFromContext(context: DispatchContext | undefined): { - verbose?: boolean; - logPath?: string; - traceLogPath?: string; - requestId?: string; -} { +function runnerOptionsFromContext( + context: DispatchContext | undefined, +): Pick { return { verbose: context?.verbose, logPath: context?.logPath, diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 1f3edd98f..f0e079020 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -146,13 +146,9 @@ export function stripInternalInteractionFlags( return publicFlags; } -export function buildInteractionSurfaceSignature(nodes: SnapshotNode[]): Array<{ - key: string; - x: number; - y: number; - width: number; - height: number; -}> { +export function buildInteractionSurfaceSignature( + nodes: SnapshotNode[], +): InteractionSurfaceSignature { const occurrenceCounts = new Map(); const entries: InteractionSurfaceSignature = []; diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index cf240704d..2b6adfa88 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -8,12 +8,7 @@ import type { BackMode, Interactor, RunnerContext } from '../../core/interactor- export type AppleBackRunnerCommand = 'backInApp' | 'backSystem'; type AppleRemoteButton = NonNullable; type RunIosRunnerCommand = typeof runIosRunnerCommand; -type RunnerOpts = { - verbose?: boolean; - logPath?: string; - traceLogPath?: string; - requestId?: string; -}; +type RunnerOpts = Pick; type InteractionFrame = { originX: number; diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index b06c55e55..ba46c19ae 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerCommand } from './runner-contract.ts'; +import type { RunnerXctestrunArtifactState, RunnerXctestrunCacheKind } from './runner-xctestrun.ts'; export type AppleRunnerCommandOptions = { verbose?: boolean; @@ -25,8 +26,8 @@ export type AppleRunnerPrepareOptions = AppleRunnerLifecycleOptions & { export type AppleRunnerPrepareResult = { runner: Record; - cache?: 'exact' | 'restore-key' | 'miss'; - artifact?: 'valid' | 'rebuilt'; + cache?: RunnerXctestrunCacheKind; + artifact?: RunnerXctestrunArtifactState; buildMs?: number; connectMs: number; healthCheckMs: number; diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index acfe19d16..111df89f9 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -110,11 +110,14 @@ export type RunnerXctestrunCacheMetadata = { artifacts?: RunnerXctestrunCacheArtifacts; }; +export type RunnerXctestrunCacheKind = 'exact' | 'restore-key' | 'miss'; +export type RunnerXctestrunArtifactState = 'valid' | 'rebuilt'; + export type RunnerXctestrunArtifact = { xctestrunPath: string; derived: string; - cache: 'exact' | 'restore-key' | 'miss'; - artifact: 'valid' | 'rebuilt'; + cache: RunnerXctestrunCacheKind; + artifact: RunnerXctestrunArtifactState; buildMs: number; xctestrunPathSource: 'manifest' | 'scan' | 'build'; reason?: string; From 287d9ca2df5fba0bba5e746999682e06b4db6d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:15:46 +0200 Subject: [PATCH 4/5] refactor(types): dedupe remaining tail shapes - ElementSelectorTarget (core/interactor-types.ts) shared by DirectIosSelectorTarget and ElementSelectorTapOptions (= Omit<...,'raw'>). - Collapse client-types ClientCommandBaseOptions into the identical exported DeviceCommandBaseOptions; merge the two byte-identical snapshot-pick aliases. - File-local SelectorRuntimeError and LineWriter for repeated inline shapes. - Export mcp ToolResult and use it for the router's textToolResult return. Pure refactor, type-only. --- src/client-types.ts | 78 +++++++++++++++---------------- src/core/interactor-types.ts | 5 +- src/daemon/app-log-stream.ts | 6 ++- src/daemon/direct-ios-selector.ts | 9 +--- src/daemon/selector-runtime.ts | 11 ++--- src/mcp/command-tools.ts | 2 +- src/mcp/router.ts | 4 +- 7 files changed, 55 insertions(+), 60 deletions(-) diff --git a/src/client-types.ts b/src/client-types.ts index 02a304364..0e2295894 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -356,8 +356,6 @@ export type CaptureScreenshotResult = { export type DeviceCommandBaseOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions; -type WaitSnapshotOptions = Pick; - type WaitCommandTarget = | { durationMs: number; @@ -366,21 +364,21 @@ type WaitCommandTarget = selector?: never; timeoutMs?: never; } - | (WaitSnapshotOptions & { + | (SelectorSnapshotCommandOptions & { text: string; durationMs?: never; ref?: never; selector?: never; timeoutMs?: number; }) - | (WaitSnapshotOptions & { + | (SelectorSnapshotCommandOptions & { ref: string; durationMs?: never; text?: never; selector?: never; timeoutMs?: number; }) - | (WaitSnapshotOptions & { + | (SelectorSnapshotCommandOptions & { selector: string; durationMs?: never; text?: never; @@ -493,11 +491,11 @@ export type ClipboardCommandResult = textLength: number; }); -export type ReactNativeCommandOptions = ClientCommandBaseOptions & { +export type ReactNativeCommandOptions = DeviceCommandBaseOptions & { action: 'dismiss-overlay'; }; -export type PrepareCommandOptions = ClientCommandBaseOptions & { +export type PrepareCommandOptions = DeviceCommandBaseOptions & { action: 'ios-runner'; timeoutMs?: number; }; @@ -519,8 +517,6 @@ export type AgentDeviceCommandClient = { type SelectorSnapshotCommandOptions = Pick; type FindSnapshotCommandOptions = Pick; -type ClientCommandBaseOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions; - type PointTarget = { x: number; y: number; @@ -557,47 +553,47 @@ type RepeatedPressOptions = { doubleTap?: boolean; }; -export type DeviceBootOptions = ClientCommandBaseOptions & { +export type DeviceBootOptions = DeviceCommandBaseOptions & { headless?: boolean; }; -export type DeviceShutdownOptions = ClientCommandBaseOptions; +export type DeviceShutdownOptions = DeviceCommandBaseOptions; -export type AppPushOptions = ClientCommandBaseOptions & { +export type AppPushOptions = DeviceCommandBaseOptions & { app: string; payload: string | Record; }; -export type AppTriggerEventOptions = ClientCommandBaseOptions & { +export type AppTriggerEventOptions = DeviceCommandBaseOptions & { event: string; payload?: Record; }; -export type CaptureDiffOptions = ClientCommandBaseOptions & +export type CaptureDiffOptions = DeviceCommandBaseOptions & Pick & { kind: 'snapshot'; out?: string; }; -export type ClickOptions = ClientCommandBaseOptions & +export type ClickOptions = DeviceCommandBaseOptions & SelectorSnapshotCommandOptions & InteractionTarget & RepeatedPressOptions & { button?: ClickButton; }; -export type PressOptions = ClientCommandBaseOptions & +export type PressOptions = DeviceCommandBaseOptions & SelectorSnapshotCommandOptions & InteractionTarget & RepeatedPressOptions; -export type LongPressOptions = ClientCommandBaseOptions & +export type LongPressOptions = DeviceCommandBaseOptions & SelectorSnapshotCommandOptions & InteractionTarget & { durationMs?: number; }; -export type SwipeOptions = ClientCommandBaseOptions & { +export type SwipeOptions = DeviceCommandBaseOptions & { from: { x: number; y: number }; to: { x: number; y: number }; durationMs?: number; @@ -606,7 +602,7 @@ export type SwipeOptions = ClientCommandBaseOptions & { pattern?: SwipePattern; }; -export type PanOptions = ClientCommandBaseOptions & { +export type PanOptions = DeviceCommandBaseOptions & { x: number; y: number; dx: number; @@ -614,7 +610,7 @@ export type PanOptions = ClientCommandBaseOptions & { durationMs?: number; }; -export type FlingOptions = ClientCommandBaseOptions & { +export type FlingOptions = DeviceCommandBaseOptions & { direction: ScrollDirection; x: number; y: number; @@ -622,63 +618,63 @@ export type FlingOptions = ClientCommandBaseOptions & { durationMs?: number; }; -export type SwipeGestureOptions = ClientCommandBaseOptions & { +export type SwipeGestureOptions = DeviceCommandBaseOptions & { preset: SwipePreset; durationMs?: number; }; -export type FocusOptions = ClientCommandBaseOptions & { +export type FocusOptions = DeviceCommandBaseOptions & { x: number; y: number; }; -export type TypeTextOptions = ClientCommandBaseOptions & { +export type TypeTextOptions = DeviceCommandBaseOptions & { text: string; delayMs?: number; }; -export type FillOptions = ClientCommandBaseOptions & +export type FillOptions = DeviceCommandBaseOptions & SelectorSnapshotCommandOptions & InteractionTarget & { text: string; delayMs?: number; }; -export type ScrollOptions = ClientCommandBaseOptions & { +export type ScrollOptions = DeviceCommandBaseOptions & { direction: ScrollInputDirection; amount?: number; pixels?: number; }; -export type PinchOptions = ClientCommandBaseOptions & { +export type PinchOptions = DeviceCommandBaseOptions & { scale: number; x?: number; y?: number; }; -export type RotateGestureOptions = ClientCommandBaseOptions & { +export type RotateGestureOptions = DeviceCommandBaseOptions & { degrees: number; x?: number; y?: number; velocity?: number; }; -export type TransformGestureOptions = ClientCommandBaseOptions & TransformGestureParams; +export type TransformGestureOptions = DeviceCommandBaseOptions & TransformGestureParams; -export type GetOptions = ClientCommandBaseOptions & +export type GetOptions = DeviceCommandBaseOptions & SelectorSnapshotCommandOptions & ElementTarget & { format: 'text' | 'attrs'; }; -type IsTextPredicateOptions = ClientCommandBaseOptions & +type IsTextPredicateOptions = DeviceCommandBaseOptions & SelectorSnapshotCommandOptions & { predicate: 'text'; selector: string; value: string; }; -type IsStatePredicateOptions = ClientCommandBaseOptions & +type IsStatePredicateOptions = DeviceCommandBaseOptions & SelectorSnapshotCommandOptions & { predicate: 'visible' | 'hidden' | 'exists' | 'editable' | 'selected'; selector: string; @@ -687,7 +683,7 @@ type IsStatePredicateOptions = ClientCommandBaseOptions & export type IsOptions = IsTextPredicateOptions | IsStatePredicateOptions; -type FindBaseOptions = ClientCommandBaseOptions & +type FindBaseOptions = DeviceCommandBaseOptions & FindSnapshotCommandOptions & { locator?: FindLocator; query: string; @@ -742,7 +738,7 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & { out?: string; }; -export type PerfOptions = ClientCommandBaseOptions & { +export type PerfOptions = DeviceCommandBaseOptions & { area?: PerfArea; action?: PerfAction; }; @@ -793,38 +789,38 @@ export type PermissionTarget = | 'input-monitoring'; export type SettingsUpdateOptions = - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'clear-app-state'; state: 'clear'; app?: string; }) - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'wifi' | 'airplane' | 'location'; state: 'on' | 'off'; }) - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'location'; state: 'set'; latitude: number; longitude: number; }) - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'animations'; state: 'on' | 'off'; }) - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'appearance'; state: 'light' | 'dark' | 'toggle'; }) - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'faceid' | 'touchid'; state: 'match' | 'nonmatch' | 'enroll' | 'unenroll'; }) - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'fingerprint'; state: 'match' | 'nonmatch'; }) - | (ClientCommandBaseOptions & { + | (DeviceCommandBaseOptions & { setting: 'permission'; state: 'grant' | 'deny' | 'reset'; permission: PermissionTarget; diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 24c22bcfd..8a1540d29 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -29,12 +29,15 @@ export type ScreenshotOptions = { export type ElementSelectorKey = 'id' | 'label' | 'text' | 'value'; -export type ElementSelectorTapOptions = { +export type ElementSelectorTarget = { key: ElementSelectorKey; value: string; + raw: string; allowNonHittableCoordinateFallback?: boolean; }; +export type ElementSelectorTapOptions = Omit; + export type SnapshotOptions = BaseSnapshotOptions & { appBundleId?: string; surface?: SessionSurface; diff --git a/src/daemon/app-log-stream.ts b/src/daemon/app-log-stream.ts index 59229ecb1..6ab3b4585 100644 --- a/src/daemon/app-log-stream.ts +++ b/src/daemon/app-log-stream.ts @@ -21,10 +21,12 @@ function redactChunk(chunk: string, patterns: RegExp[]): string { return output; } +type LineWriter = { onChunk: (chunk: string) => void; flush: () => void }; + export function createLineWriter( stream: fs.WriteStream, options: { redactionPatterns: RegExp[]; includeTokens?: string[] }, -): { onChunk: (chunk: string) => void; flush: () => void } { +): LineWriter { const includeTokens = options.includeTokens?.filter((token) => token.length > 0) ?? []; let pending = ''; @@ -67,7 +69,7 @@ export function attachChildToStream( stream: fs.WriteStream, options: { endStreamOnClose: boolean; - writer: { onChunk: (chunk: string) => void; flush: () => void }; + writer: LineWriter; }, ): Promise { const stdout = child.stdout; diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index bf91a962c..2e5f3ef4f 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -1,14 +1,9 @@ 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'; +import type { ElementSelectorTarget } from '../core/interactor-types.ts'; -export type DirectIosSelectorTarget = { - key: ElementSelectorKey; - value: string; - raw: string; - allowNonHittableCoordinateFallback?: boolean; -}; +export type DirectIosSelectorTarget = ElementSelectorTarget; export function readSimpleIosSelectorTarget(params: { session: SessionState | undefined; diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index b9e8525bf..21c70fd85 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -80,10 +80,9 @@ type DirectIosSelectorQueryResult = { node?: SnapshotNode; }; -type DirectIosSelectorFallbackResult = - | DirectIosSelectorQueryResult - | { kind: 'error'; response: DaemonResponse } - | null; +type SelectorRuntimeError = { kind: 'error'; response: DaemonResponse }; + +type DirectIosSelectorFallbackResult = DirectIosSelectorQueryResult | SelectorRuntimeError | null; type ResolvedDirectIosSelectorQuery = | { @@ -91,7 +90,7 @@ type ResolvedDirectIosSelectorQuery = selector: DirectIosSelectorTarget; result: DirectIosSelectorQueryResult; } - | { kind: 'error'; response: DaemonResponse } + | SelectorRuntimeError | null; export async function dispatchFindReadOnlyViaRuntime( @@ -419,7 +418,7 @@ async function queryDirectIosSelectorOrFallback( function isDirectIosSelectorErrorResult( result: DirectIosSelectorFallbackResult | ResolvedDirectIosSelectorQuery, -): result is { kind: 'error'; response: DaemonResponse } { +): result is SelectorRuntimeError { return result !== null && 'kind' in result && result.kind === 'error'; } diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index a89a13077..95367d27d 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -6,7 +6,7 @@ import { type CommandName, } from '../commands/command-metadata.ts'; -type ToolResult = { +export type ToolResult = { isError: boolean; structuredContent?: unknown; content: Array<{ type: 'text'; text: string }>; diff --git a/src/mcp/router.ts b/src/mcp/router.ts index db4f51ed0..4edf865b8 100644 --- a/src/mcp/router.ts +++ b/src/mcp/router.ts @@ -1,4 +1,4 @@ -import { listCommandTools, commandToolExecutor } from './command-tools.ts'; +import { listCommandTools, commandToolExecutor, type ToolResult } from './command-tools.ts'; import { readVersion } from '../utils/version.ts'; import type { JsonRpcId, JsonRpcRequestEnvelope } from '../contracts.ts'; @@ -70,7 +70,7 @@ function supportedProtocolVersion(_params: unknown): string { return SUPPORTED_PROTOCOL_VERSION; } -function textToolResult(text: string, isError = false): unknown { +function textToolResult(text: string, isError = false): ToolResult { return { isError, content: [{ type: 'text', text }], From 01b972a8fb226cd79da7d54f32bdbd49c2088082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 10 Jun 2026 01:42:01 +0200 Subject: [PATCH 5/5] address review: adopt helpers, fix alias direction, tidy tail dedupes - Adopt parseStringMember/isStringMember at parseGestureDirection and the swipe-pattern guard in dispatch-interactions.ts (same module the PR touched; byte-identical error/behavior). - Extract RunnerCallOptions = Pick in interactor-types.ts; reuse in dispatch-interactions + ios/interactions (was written twice). - Invert the ElementSelector alias direction: ElementSelectorTapOptions stays the plain core type; DirectIosSelectorTarget = ElementSelectorTapOptions & { raw } (keeps the daemon-only raw field in the daemon module). - Drop redundant cleanStaleBundles in AppleRunnerLifecycleOptions (already in AppleRunnerCommandOptions); drop the now-unnecessary cast in isStringMember. - callTool returns Promise; rename SelectorRuntimeError -> DirectIosSelectorErrorResult (matches the DirectIosSelector* family). - Dedupe the log-backend device mapping: export resolveLogBackend and reuse it in resolveSessionLogBackendLabel. Pure refactor; typecheck/lint green, affected tests pass. --- src/core/dispatch-interactions.ts | 18 +++++++++--------- src/core/interactor-types.ts | 11 +++++++---- src/daemon/app-log.ts | 2 +- src/daemon/direct-ios-selector.ts | 4 ++-- src/daemon/handlers/session-observability.ts | 12 ++---------- src/daemon/selector-runtime.ts | 11 +++++++---- src/mcp/router.ts | 2 +- src/platforms/ios/interactions.ts | 9 +++++++-- src/platforms/ios/runner-provider.ts | 1 - src/utils/string-enum.ts | 2 +- 10 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index a461cf5c5..bfd07e6c3 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -7,11 +7,14 @@ import { inferGestureReferenceFrame, parseScrollDirection, parseSwipePreset, + SCROLL_DIRECTIONS, + SWIPE_PATTERNS, type ScrollDirection, type SwipePattern, type SwipePreset, type TransformGestureParams, } from './scroll-gesture.ts'; +import { isStringMember, parseStringMember } from '../utils/string-enum.ts'; import { getClickButtonValidationError, resolveClickButton, @@ -32,7 +35,7 @@ import { runRepeatedSeries, } from './dispatch-series.ts'; import type { DispatchContext } from './dispatch-context.ts'; -import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { Interactor, RunnerCallOptions } from './interactor-types.ts'; export async function handleLongPressCommand( interactor: Interactor, @@ -386,9 +389,7 @@ async function runDirectPressSeries( ); } -function runnerOptionsFromContext( - context: DispatchContext | undefined, -): Pick { +function runnerOptionsFromContext(context: DispatchContext | undefined): RunnerCallOptions { return { verbose: context?.verbose, logPath: context?.logPath, @@ -469,7 +470,7 @@ async function runSwipeCoordinates(params: { const count = requireIntInRange(context?.count ?? 1, 'count', 1, 200); const pauseMs = requireIntInRange(context?.pauseMs ?? 0, 'pause-ms', 0, 10_000); const pattern = context?.pattern ?? 'one-way'; - if (pattern !== 'one-way' && pattern !== 'ping-pong') { + if (!isStringMember(SWIPE_PATTERNS, pattern)) { throw new AppError('INVALID_ARGS', `Invalid pattern: ${pattern}`); } @@ -829,10 +830,9 @@ function parseOptionalGestureCenter( } function parseGestureDirection(input: string | undefined, field: string): ScrollDirection { - if (input === 'up' || input === 'down' || input === 'left' || input === 'right') { - return input; - } - throw new AppError('INVALID_ARGS', `${field} must be up, down, left, or right`); + return parseStringMember(SCROLL_DIRECTIONS, input, { + message: `${field} must be up, down, left, or right`, + }); } function requireFinitePositiveNumber(value: number, field: string): number { diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 8a1540d29..9966d2cca 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -18,6 +18,12 @@ export type RunnerContext = { traceLogPath?: string; }; +/** Subset of {@link RunnerContext} forwarded to runner command invocations. */ +export type RunnerCallOptions = Pick< + RunnerContext, + 'verbose' | 'logPath' | 'traceLogPath' | 'requestId' +>; + export type { BackMode }; export type ScreenshotOptions = { @@ -29,15 +35,12 @@ export type ScreenshotOptions = { export type ElementSelectorKey = 'id' | 'label' | 'text' | 'value'; -export type ElementSelectorTarget = { +export type ElementSelectorTapOptions = { key: ElementSelectorKey; value: string; - raw: string; allowNonHittableCoordinateFallback?: boolean; }; -export type ElementSelectorTapOptions = Omit; - export type SnapshotOptions = BaseSnapshotOptions & { appBundleId?: string; surface?: SessionSurface; diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index 205f7cd93..745180eb3 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -176,7 +176,7 @@ export function getAppLogPathMetadata(outPath: string): { }; } -function resolveLogBackend(device: DeviceInfo): LogBackend { +export function resolveLogBackend(device: DeviceInfo): LogBackend { if (device.platform === 'macos') return 'macos'; if (device.platform === 'ios') { return device.kind === 'device' ? 'ios-device' : 'ios-simulator'; diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 2e5f3ef4f..a7b8f6114 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -1,9 +1,9 @@ import type { SessionState } from './types.ts'; import { tryParseSelectorChain } from './selectors.ts'; import { asAppError } from '../utils/errors.ts'; -import type { ElementSelectorTarget } from '../core/interactor-types.ts'; +import type { ElementSelectorTapOptions } from '../core/interactor-types.ts'; -export type DirectIosSelectorTarget = ElementSelectorTarget; +export type DirectIosSelectorTarget = ElementSelectorTapOptions & { raw: string }; export function readSimpleIosSelectorTarget(params: { session: SessionState | undefined; diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 1fbb1a275..10f734381 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -14,6 +14,7 @@ import { clearAppLogFiles, getAppLogPathMetadata, readSessionNetworkCapture, + resolveLogBackend, runAppLogDoctor, startAppLog, stopAppLog, @@ -67,16 +68,7 @@ const LOG_ACTION_HANDLERS: Record< }; function resolveSessionLogBackendLabel(session: SessionState): LogBackend { - if (session.appLog) { - return session.appLog.backend; - } - if (session.device.platform === 'macos') { - return 'macos'; - } - if (session.device.platform === 'ios') { - return session.device.kind === 'device' ? 'ios-device' : 'ios-simulator'; - } - return 'android'; + return session.appLog?.backend ?? resolveLogBackend(session.device); } export async function handleSessionObservabilityCommands( diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 21c70fd85..def89f7fa 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -80,9 +80,12 @@ type DirectIosSelectorQueryResult = { node?: SnapshotNode; }; -type SelectorRuntimeError = { kind: 'error'; response: DaemonResponse }; +type DirectIosSelectorErrorResult = { kind: 'error'; response: DaemonResponse }; -type DirectIosSelectorFallbackResult = DirectIosSelectorQueryResult | SelectorRuntimeError | null; +type DirectIosSelectorFallbackResult = + | DirectIosSelectorQueryResult + | DirectIosSelectorErrorResult + | null; type ResolvedDirectIosSelectorQuery = | { @@ -90,7 +93,7 @@ type ResolvedDirectIosSelectorQuery = selector: DirectIosSelectorTarget; result: DirectIosSelectorQueryResult; } - | SelectorRuntimeError + | DirectIosSelectorErrorResult | null; export async function dispatchFindReadOnlyViaRuntime( @@ -418,7 +421,7 @@ async function queryDirectIosSelectorOrFallback( function isDirectIosSelectorErrorResult( result: DirectIosSelectorFallbackResult | ResolvedDirectIosSelectorQuery, -): result is SelectorRuntimeError { +): result is DirectIosSelectorErrorResult { return result !== null && 'kind' in result && result.kind === 'error'; } diff --git a/src/mcp/router.ts b/src/mcp/router.ts index 4edf865b8..a3684e12f 100644 --- a/src/mcp/router.ts +++ b/src/mcp/router.ts @@ -56,7 +56,7 @@ async function handleRequest(method: string, params: unknown): Promise } } -async function callTool(params: unknown): Promise { +async function callTool(params: unknown): Promise { const record = asRecord(params); const name = stringField(record, 'name'); try { diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index 2b6adfa88..aff2834e1 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -3,12 +3,17 @@ import type { DeviceInfo } from '../../utils/device.ts'; import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-gesture.ts'; import { runIosRunnerCommand } from './runner-client.ts'; import type { RunnerCommand } from './runner-contract.ts'; -import type { BackMode, Interactor, RunnerContext } from '../../core/interactor-types.ts'; +import type { + BackMode, + Interactor, + RunnerCallOptions, + RunnerContext, +} from '../../core/interactor-types.ts'; export type AppleBackRunnerCommand = 'backInApp' | 'backSystem'; type AppleRemoteButton = NonNullable; type RunIosRunnerCommand = typeof runIosRunnerCommand; -type RunnerOpts = Pick; +type RunnerOpts = RunnerCallOptions; type InteractionFrame = { originX: number; diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index ba46c19ae..d8a7b42c4 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -13,7 +13,6 @@ export type AppleRunnerCommandOptions = { }; export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { - cleanStaleBundles?: boolean; buildTimeoutMs?: number; forceRunnerXctestrunRebuild?: boolean; }; diff --git a/src/utils/string-enum.ts b/src/utils/string-enum.ts index ef5159467..3c51fdaba 100644 --- a/src/utils/string-enum.ts +++ b/src/utils/string-enum.ts @@ -8,7 +8,7 @@ export function isStringMember( values: T, value: string, ): value is T[number] { - return (values as readonly string[]).includes(value); + return values.includes(value); } /**