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/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/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index a2982b396..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 } from './interactor-types.ts'; +import type { Interactor, RunnerCallOptions } from './interactor-types.ts'; export async function handleLongPressCommand( interactor: Interactor, @@ -386,12 +389,7 @@ async function runDirectPressSeries( ); } -function runnerOptionsFromContext(context: DispatchContext | undefined): { - verbose?: boolean; - logPath?: string; - traceLogPath?: string; - requestId?: string; -} { +function runnerOptionsFromContext(context: DispatchContext | undefined): RunnerCallOptions { return { verbose: context?.verbose, logPath: context?.logPath, @@ -472,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}`); } @@ -832,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 24c22bcfd..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 = { 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/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-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/app-log.ts b/src/daemon/app-log.ts index 2229d9783..745180eb3 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 { +export 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/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index bf91a962c..a7b8f6114 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 { ElementSelectorTapOptions } from '../core/interactor-types.ts'; -export type DirectIosSelectorTarget = { - key: ElementSelectorKey; - value: string; - raw: string; - allowNonHittableCoordinateFallback?: boolean; -}; +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 0ba4a0442..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, @@ -21,7 +22,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,17 +67,8 @@ const LOG_ACTION_HANDLERS: Record< handleLogsStop(session, sessionName, sessionStore), }; -function resolveSessionLogBackendLabel(session: SessionState): NetworkLogBackend { - 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'; +function resolveSessionLogBackendLabel(session: SessionState): LogBackend { + return session.appLog?.backend ?? resolveLogBackend(session.device); } export async function handleSessionObservabilityCommands( 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/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/selector-runtime.ts b/src/daemon/selector-runtime.ts index b9e8525bf..def89f7fa 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -80,9 +80,11 @@ type DirectIosSelectorQueryResult = { node?: SnapshotNode; }; +type DirectIosSelectorErrorResult = { kind: 'error'; response: DaemonResponse }; + type DirectIosSelectorFallbackResult = | DirectIosSelectorQueryResult - | { kind: 'error'; response: DaemonResponse } + | DirectIosSelectorErrorResult | null; type ResolvedDirectIosSelectorQuery = @@ -91,7 +93,7 @@ type ResolvedDirectIosSelectorQuery = selector: DirectIosSelectorTarget; result: DirectIosSelectorQueryResult; } - | { kind: 'error'; response: DaemonResponse } + | DirectIosSelectorErrorResult | null; export async function dispatchFindReadOnlyViaRuntime( @@ -419,7 +421,7 @@ async function queryDirectIosSelectorOrFallback( function isDirectIosSelectorErrorResult( result: DirectIosSelectorFallbackResult | ResolvedDirectIosSelectorQuery, -): result is { kind: 'error'; response: DaemonResponse } { +): result is DirectIosSelectorErrorResult { return result !== null && 'kind' in result && result.kind === 'error'; } 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; 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..a3684e12f 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'; @@ -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 { @@ -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 }], diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index cf240704d..aff2834e1 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -3,17 +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 = { - verbose?: boolean; - logPath?: string; - traceLogPath?: string; - requestId?: string; -}; +type RunnerOpts = RunnerCallOptions; type InteractionFrame = { originX: number; diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index b06c55e55..d8a7b42c4 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; @@ -12,7 +13,6 @@ export type AppleRunnerCommandOptions = { }; export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { - cleanStaleBundles?: boolean; buildTimeoutMs?: number; forceRunnerXctestrunRebuild?: boolean; }; @@ -25,8 +25,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; diff --git a/src/utils/string-enum.ts b/src/utils/string-enum.ts new file mode 100644 index 000000000..3c51fdaba --- /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.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('|')}.`, + ); +}