diff --git a/skills/agent-device/references/exploration.md b/skills/agent-device/references/exploration.md index 0290dc58a..ed8145a7c 100644 --- a/skills/agent-device/references/exploration.md +++ b/skills/agent-device/references/exploration.md @@ -338,6 +338,7 @@ Common batch error categories: - `SESSION_NOT_FOUND`: open or select the correct session, then retry. - `UNSUPPORTED_OPERATION`: switch to a supported command or surface. - `AMBIGUOUS_MATCH`: refine the selector or locator, then retry the failed step. +- `DEVICE_IN_USE`: the device is held by another session — close or reuse the existing session before retrying. - `COMMAND_FAILED`: add sync guards and retry from the failing step. ## Stop conditions diff --git a/src/cli.ts b/src/cli.ts index 59d901181..c468be77a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -336,9 +336,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): process.stderr.write(`\n[daemon log]\n${tail}\n`); } } - } catch { - // ignore - } + } catch {} } } if (logTailStopper) logTailStopper(); diff --git a/src/cli/commands/apps.ts b/src/cli/commands/apps.ts index 220479304..64dbda1c0 100644 --- a/src/cli/commands/apps.ts +++ b/src/cli/commands/apps.ts @@ -1,5 +1,5 @@ import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const appsCommand: ClientCommandHandler = async ({ flags, client }) => { const apps = await client.apps.list({ diff --git a/src/cli/commands/client-command.ts b/src/cli/commands/client-command.ts index c0f27e5ba..fccd549eb 100644 --- a/src/cli/commands/client-command.ts +++ b/src/cli/commands/client-command.ts @@ -13,7 +13,7 @@ import { AppError } from '../../utils/errors.ts'; import { parseWaitArgs } from '../../daemon/handlers/snapshot.ts'; import { parseDeviceRotation } from '../../core/device-rotation.ts'; import { buildSelectionOptions, writeCommandMessage, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandlerMap } from './router.ts'; +import type { ClientCommandHandlerMap } from './router-types.ts'; export const clientCommandMethodHandlers = { [CLIENT_COMMANDS.wait]: async ({ positionals, flags, client }) => { diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 60e2195f8..f11c19ee6 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,6 +1,7 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { stopMetroTunnel } from '../../metro.ts'; import { resolveRemoteConfigProfile } from '../../remote-config.ts'; +import type { MetroBridgeScope } from '../../client-metro-companion-contract.ts'; import { buildRemoteConnectionDaemonState, hashRemoteConfigFile, @@ -173,11 +174,7 @@ export async function prepareConnectedMetro( client: AgentDeviceClient, remoteConfigPath: string, session: string, - bridgeScope: { - tenantId: string; - runId: string; - leaseId: string; - }, + bridgeScope: MetroBridgeScope, ): Promise<{ runtime?: SessionRuntimeHints; cleanup?: NonNullable; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 238fbf6eb..d6da9c675 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -21,7 +21,7 @@ import { import { writeCommandOutput } from './shared.ts'; import type { LeaseBackend } from '../../contracts.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const connectCommand: ClientCommandHandler = async ({ flags, client }) => { if (!flags.remoteConfig) { diff --git a/src/cli/commands/devices.ts b/src/cli/commands/devices.ts index c70304254..57d14e361 100644 --- a/src/cli/commands/devices.ts +++ b/src/cli/commands/devices.ts @@ -1,7 +1,7 @@ import { serializeDevice } from '../../client-shared.ts'; import type { AgentDeviceDevice } from '../../client.ts'; import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const devicesCommand: ClientCommandHandler = async ({ flags, client }) => { const devices = await client.devices.list(buildSelectionOptions(flags)); diff --git a/src/cli/commands/ensure-simulator.ts b/src/cli/commands/ensure-simulator.ts index fda2c4ae6..886f34de5 100644 --- a/src/cli/commands/ensure-simulator.ts +++ b/src/cli/commands/ensure-simulator.ts @@ -1,7 +1,7 @@ import { AppError } from '../../utils/errors.ts'; import { serializeEnsureSimulatorResult } from '../../client-shared.ts'; import { writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const ensureSimulatorCommand: ClientCommandHandler = async ({ flags, client }) => { if (!flags.device) { diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 1d4aff758..2d400eaef 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -14,7 +14,7 @@ import { AppError } from '../../utils/errors.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; import { buildSelectionOptions } from './shared.ts'; import { writeCommandCliOutput } from './output.ts'; -import type { ClientCommandHandler, ClientCommandHandlerMap } from './router.ts'; +import type { ClientCommandHandler, ClientCommandHandlerMap } from './router-types.ts'; type GenericClientCommandRunner = (params: { client: AgentDeviceClient; diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index ac3d1f1ea..bfc64b814 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -3,7 +3,7 @@ import { serializeDeployResult, serializeInstallFromSourceResult } from '../../c import type { CliFlags } from '../../utils/command-schema.ts'; import type { AgentDeviceClient, AppDeployResult } from '../../client.ts'; import { buildSelectionOptions, writeCommandMessage } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const installCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const result = await runDeployCommand('install', positionals, flags, client); diff --git a/src/cli/commands/metro.ts b/src/cli/commands/metro.ts index d30154006..9c4f07666 100644 --- a/src/cli/commands/metro.ts +++ b/src/cli/commands/metro.ts @@ -1,6 +1,6 @@ import { AppError } from '../../utils/errors.ts'; import { writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const metroCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const action = (positionals[0] ?? '').toLowerCase(); diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index 56cace606..209abc6b4 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -1,6 +1,6 @@ import { serializeCloseResult, serializeOpenResult } from '../../client-shared.ts'; import { buildSelectionOptions, writeCommandMessage } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const openCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const result = await client.apps.open({ diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts new file mode 100644 index 000000000..6319435e2 --- /dev/null +++ b/src/cli/commands/router-types.ts @@ -0,0 +1,11 @@ +import type { CliFlags } from '../../utils/command-schema.ts'; +import type { AgentDeviceClient } from '../../client.ts'; + +export type ClientCommandParams = { + positionals: string[]; + flags: CliFlags; + client: AgentDeviceClient; +}; + +export type ClientCommandHandler = (params: ClientCommandParams) => Promise; +export type ClientCommandHandlerMap = Partial>; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 40e15e1aa..5b15a4e29 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -13,15 +13,16 @@ import { snapshotCommand } from './snapshot.ts'; import { screenshotCommand, diffCommand } from './screenshot.ts'; import { clientCommandMethodHandlers } from './client-command.ts'; import { genericClientCommandHandlers } from './generic.ts'; +import type { + ClientCommandHandler, + ClientCommandHandlerMap, +} from './router-types.ts'; -export type ClientCommandParams = { - positionals: string[]; - flags: CliFlags; - client: AgentDeviceClient; -}; - -export type ClientCommandHandler = (params: ClientCommandParams) => Promise; -export type ClientCommandHandlerMap = Partial>; +export type { + ClientCommandHandler, + ClientCommandHandlerMap, + ClientCommandParams, +} from './router-types.ts'; const dedicatedClientApiHandlers = { session: sessionCommand, diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index acf8d85a9..51e4f9e7f 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -7,7 +7,7 @@ import { createLocalArtifactAdapter } from '../../io.ts'; import { createAgentDevice, localCommandPolicy } from '../../runtime.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const screenshotCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const result = await client.capture.screenshot({ diff --git a/src/cli/commands/session.ts b/src/cli/commands/session.ts index bb70da76b..884881419 100644 --- a/src/cli/commands/session.ts +++ b/src/cli/commands/session.ts @@ -1,7 +1,7 @@ import { AppError } from '../../utils/errors.ts'; import { serializeSessionListEntry } from '../../client-shared.ts'; import { writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const sessionCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const sub = positionals[0] ?? 'list'; diff --git a/src/cli/commands/snapshot.ts b/src/cli/commands/snapshot.ts index 6ae1d8872..3234d7fcb 100644 --- a/src/cli/commands/snapshot.ts +++ b/src/cli/commands/snapshot.ts @@ -1,7 +1,7 @@ import { formatSnapshotText } from '../../utils/output.ts'; import { serializeSnapshotResult } from '../../client-shared.ts'; import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router.ts'; +import type { ClientCommandHandler } from './router-types.ts'; export const snapshotCommand: ClientCommandHandler = async ({ flags, client }) => { const result = await client.capture.snapshot({ diff --git a/src/client-metro-companion-contract.ts b/src/client-metro-companion-contract.ts index 7d51a60e1..8d18f03f7 100644 --- a/src/client-metro-companion-contract.ts +++ b/src/client-metro-companion-contract.ts @@ -1,5 +1,3 @@ -import type { MetroBridgeScope } from './client-metro.ts'; - export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion'; export const METRO_COMPANION_RECONNECT_DELAY_MS = 1_000; export const METRO_COMPANION_LEASE_CHECK_INTERVAL_MS = 250; @@ -14,8 +12,11 @@ export const ENV_SCOPE_TENANT_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID export const ENV_SCOPE_RUN_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID'; export const ENV_SCOPE_LEASE_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID'; -export type { MetroTunnelRequestMessage as MetroCompanionRequest } from './metro.ts'; -export type { MetroBridgeScope }; +export type MetroBridgeScope = { + tenantId: string; + runId: string; + leaseId: string; +}; export type CompanionOptions = { serverBaseUrl: string; diff --git a/src/client-metro-companion-worker.ts b/src/client-metro-companion-worker.ts index ee5491cf3..f97710cf5 100644 --- a/src/client-metro-companion-worker.ts +++ b/src/client-metro-companion-worker.ts @@ -14,7 +14,11 @@ import { METRO_COMPANION_RUN_ARG, WS_READY_STATE_OPEN, } from './client-metro-companion-contract.ts'; -import type { CompanionOptions, MetroCompanionRequest } from './client-metro-companion-contract.ts'; +import type { CompanionOptions } from './client-metro-companion-contract.ts'; +import type { + MetroTunnelRequestMessage as MetroCompanionRequest, + MetroTunnelResponseMessage, +} from './metro.ts'; import { normalizeBaseUrl } from './utils/url.ts'; function createHeaders(serverBaseUrl: string, token: string): Record { @@ -87,7 +91,7 @@ function normalizeOutgoingCloseCode(code: number): number { return 3001; } -function sendJson(socket: WebSocket, payload: object): void { +function sendJson(socket: WebSocket, payload: MetroTunnelResponseMessage): void { if (socket.readyState !== WS_READY_STATE_OPEN) return; socket.send(JSON.stringify(payload)); } diff --git a/src/client-metro.ts b/src/client-metro.ts index 70ec80cf5..17936ddec 100644 --- a/src/client-metro.ts +++ b/src/client-metro.ts @@ -1,12 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; +import { sleep } from './utils/timeouts.ts'; import { ensureMetroCompanion } from './client-metro-companion.ts'; +import type { MetroBridgeScope } from './client-metro-companion-contract.ts'; import type { MetroBridgeDescriptor, MetroBridgeResult, MetroBridgeRuntimePayload, MetroRuntimeHints, -} from './metro.ts'; +} from './metro-types.ts'; import { AppError } from './utils/errors.ts'; import { runCmdSync, runCmdDetached } from './utils/exec.ts'; import { resolveUserPath } from './utils/path-resolution.ts'; @@ -20,11 +22,7 @@ export type MetroPrepareKind = 'auto' | 'react-native' | 'expo'; type ResolvedMetroKind = Exclude; type EnvSource = NodeJS.ProcessEnv | Record; -export type MetroBridgeScope = { - tenantId: string; - runId: string; - leaseId: string; -}; +export type { MetroBridgeScope }; type PackageJsonShape = { dependencies?: Record; @@ -218,7 +216,7 @@ function installDependenciesIfNeeded( } async function wait(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); + await sleep(ms); } async function fetchText( diff --git a/src/client-types.ts b/src/client-types.ts index 3d1e31026..b610bf191 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -8,8 +8,12 @@ import type { SessionRuntimeHints, } from './contracts.ts'; import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts'; +import type { FindLocator } from './utils/finders.ts'; import type { ScreenshotOverlayRef, SnapshotNode, SnapshotVisibility } from './utils/snapshot.ts'; import type { MetroPrepareKind, PrepareMetroRuntimeResult } from './client-metro.ts'; +import type { MetroBridgeScope } from './client-metro-companion-contract.ts'; + +export type { FindLocator } from './utils/finders.ts'; type DaemonTransportMode = 'auto' | 'socket' | 'http'; type DaemonServerMode = 'socket' | 'http' | 'dual'; @@ -267,11 +271,7 @@ export type MetroPrepareOptions = { publicBaseUrl: string; proxyBaseUrl?: string; bearerToken?: string; - bridgeScope?: { - tenantId: string; - runId: string; - leaseId: string; - }; + bridgeScope?: MetroBridgeScope; launchUrl?: string; companionProfileKey?: string; companionConsumerKey?: string; @@ -594,8 +594,6 @@ type IsStatePredicateOptions = ClientCommandBaseOptions & export type IsOptions = IsTextPredicateOptions | IsStatePredicateOptions; -export type FindLocator = 'any' | 'text' | 'label' | 'value' | 'role' | 'id'; - type FindBaseOptions = ClientCommandBaseOptions & FindSnapshotCommandOptions & { locator?: FindLocator; diff --git a/src/commands/admin.ts b/src/commands/admin.ts index 5be9bd065..0020a0fc8 100644 --- a/src/commands/admin.ts +++ b/src/commands/admin.ts @@ -9,10 +9,10 @@ import type { BackendInstallResult, BackendInstallSource, } from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +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 './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { resolveCommandInput } from './io-policy.ts'; import { toBackendContext } from './selector-read-utils.ts'; diff --git a/src/commands/apps.ts b/src/commands/apps.ts index 70d7e4ee2..0f1ff4fe0 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -8,11 +8,11 @@ import type { BackendPushInput, } from '../backend.ts'; import type { FileInputRef } from '../io.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-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 './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; const APP_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/; const MAX_APP_EVENT_PAYLOAD_BYTES = 8 * 1024; diff --git a/src/commands/capture-diff-screenshot.ts b/src/commands/capture-diff-screenshot.ts index 810719fd2..378b4e3c8 100644 --- a/src/commands/capture-diff-screenshot.ts +++ b/src/commands/capture-diff-screenshot.ts @@ -8,11 +8,11 @@ import type { ReservedOutputFile, ResolvedInputFile, } from '../io.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import { AppError } from '../utils/errors.ts'; import { compareScreenshots, type ScreenshotDiffResult } from '../utils/screenshot-diff.ts'; import { attachCurrentOverlayMatches } from '../utils/screenshot-diff-overlay-matches.ts'; -import type { RuntimeCommand } from './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { createCommandTempFile, reserveCommandOutput, resolveCommandInput } from './io-policy.ts'; export type LiveScreenshotInputRef = { diff --git a/src/commands/capture-screenshot.ts b/src/commands/capture-screenshot.ts index 882f2c7a6..fffc80f1f 100644 --- a/src/commands/capture-screenshot.ts +++ b/src/commands/capture-screenshot.ts @@ -1,7 +1,7 @@ import { AppError } from '../utils/errors.ts'; import { successText } from '../utils/success-text.ts'; import type { ArtifactDescriptor } from '../io.ts'; -import type { RuntimeCommand, ScreenshotCommandOptions } from './index.ts'; +import type { RuntimeCommand, ScreenshotCommandOptions } from './runtime-types.ts'; import { reserveCommandOutput } from './io-policy.ts'; export type ScreenshotCommandResult = { diff --git a/src/commands/capture-snapshot.ts b/src/commands/capture-snapshot.ts index 7eb19d431..c47b048d7 100644 --- a/src/commands/capture-snapshot.ts +++ b/src/commands/capture-snapshot.ts @@ -1,16 +1,19 @@ import type { BackendSnapshotResult } from '../backend.ts'; -import type { AgentDeviceRuntime, CommandSessionRecord } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandSessionRecord } from '../runtime-contract.ts'; import { AppError } from '../utils/errors.ts'; import { buildSnapshotDiff, countSnapshotComparableLines } from '../utils/snapshot-diff.ts'; +import type { SnapshotDiffLine, SnapshotDiffSummary } from '../utils/snapshot-diff.ts'; import type { SnapshotNode, SnapshotState, SnapshotVisibility } from '../utils/snapshot.ts'; import { buildSnapshotVisibility } from '../utils/snapshot-visibility.ts'; import type { DiffSnapshotCommandOptions, RuntimeCommand, SnapshotCommandOptions, -} from './index.ts'; +} from './runtime-types.ts'; import { now } from './selector-read-utils.ts'; +export type { SnapshotDiffLine, SnapshotDiffSummary } from '../utils/snapshot-diff.ts'; + export type SnapshotCommandResult = { nodes: SnapshotNode[]; truncated: boolean; @@ -20,17 +23,6 @@ export type SnapshotCommandResult = { warnings?: string[]; }; -export type SnapshotDiffLine = { - kind: 'added' | 'removed' | 'unchanged'; - text: string; -}; - -export type SnapshotDiffSummary = { - additions: number; - removals: number; - unchanged: number; -}; - export type DiffSnapshotCommandResult = { mode: 'snapshot'; baselineInitialized: boolean; diff --git a/src/commands/diagnostics-format.ts b/src/commands/diagnostics-format.ts index 6eb0191c4..8dfb869c1 100644 --- a/src/commands/diagnostics-format.ts +++ b/src/commands/diagnostics-format.ts @@ -8,7 +8,7 @@ import type { DiagnosticsLogsCommandResult, DiagnosticsNetworkCommandResult, DiagnosticsPerfCommandResult, -} from './diagnostics.ts'; +} from './diagnostics-types.ts'; const PAYLOAD_MAX_CHARS = 2048; const MESSAGE_MAX_CHARS = 4096; diff --git a/src/commands/diagnostics-types.ts b/src/commands/diagnostics-types.ts new file mode 100644 index 000000000..073aa63f3 --- /dev/null +++ b/src/commands/diagnostics-types.ts @@ -0,0 +1,36 @@ +import type { + BackendDiagnosticsTimeWindow, + BackendLogEntry, + BackendNetworkEntry, + BackendPerfMetric, +} from '../backend.ts'; + +export type DiagnosticsLogsCommandResult = { + kind: 'diagnosticsLogs'; + entries: readonly BackendLogEntry[]; + nextCursor?: string; + timeWindow?: BackendDiagnosticsTimeWindow; + backend?: string; + redacted: boolean; + notes?: readonly string[]; +}; + +export type DiagnosticsNetworkCommandResult = { + kind: 'diagnosticsNetwork'; + entries: readonly BackendNetworkEntry[]; + nextCursor?: string; + timeWindow?: BackendDiagnosticsTimeWindow; + backend?: string; + redacted: boolean; + notes?: readonly string[]; +}; + +export type DiagnosticsPerfCommandResult = { + kind: 'diagnosticsPerf'; + metrics: readonly BackendPerfMetric[]; + startedAt?: string; + endedAt?: string; + backend?: string; + redacted: boolean; + notes?: readonly string[]; +}; diff --git a/src/commands/diagnostics.ts b/src/commands/diagnostics.ts index 1ded0b973..b7fede26b 100644 --- a/src/commands/diagnostics.ts +++ b/src/commands/diagnostics.ts @@ -3,18 +3,20 @@ import type { BackendDiagnosticsPageOptions, BackendDiagnosticsTimeWindow, BackendDumpNetworkOptions, - BackendLogEntry, BackendMeasurePerfOptions, - BackendNetworkEntry, BackendNetworkIncludeMode, - BackendPerfMetric, BackendReadLogsOptions, } from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import { AppError } from '../utils/errors.ts'; import { requireIntInRange } from '../utils/validation.ts'; import { formatLogsResult, formatNetworkResult, formatPerfResult } from './diagnostics-format.ts'; -import type { RuntimeCommand } from './index.ts'; +import type { + DiagnosticsLogsCommandResult, + DiagnosticsNetworkCommandResult, + DiagnosticsPerfCommandResult, +} from './diagnostics-types.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { toBackendContext } from './selector-read-utils.ts'; export type DiagnosticsPageOptions = CommandContext & { @@ -45,35 +47,11 @@ export type DiagnosticsPerfCommandOptions = CommandContext & { metrics?: readonly string[]; }; -export type DiagnosticsLogsCommandResult = { - kind: 'diagnosticsLogs'; - entries: readonly BackendLogEntry[]; - nextCursor?: string; - timeWindow?: BackendDiagnosticsTimeWindow; - backend?: string; - redacted: boolean; - notes?: readonly string[]; -}; - -export type DiagnosticsNetworkCommandResult = { - kind: 'diagnosticsNetwork'; - entries: readonly BackendNetworkEntry[]; - nextCursor?: string; - timeWindow?: BackendDiagnosticsTimeWindow; - backend?: string; - redacted: boolean; - notes?: readonly string[]; -}; - -export type DiagnosticsPerfCommandResult = { - kind: 'diagnosticsPerf'; - metrics: readonly BackendPerfMetric[]; - startedAt?: string; - endedAt?: string; - backend?: string; - redacted: boolean; - notes?: readonly string[]; -}; +export type { + DiagnosticsLogsCommandResult, + DiagnosticsNetworkCommandResult, + DiagnosticsPerfCommandResult, +} from './diagnostics-types.ts'; const LOG_LIMIT_DEFAULT = 100; const LOG_LIMIT_MAX = 500; diff --git a/src/commands/index.ts b/src/commands/index.ts index da73bb6e8..7dc08fc77 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,5 +1,11 @@ -import type { FileOutputRef } from '../io.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime } from '../runtime-contract.ts'; +import type { + BoundRuntimeCommand, + DiffSnapshotCommandOptions, + RuntimeCommand, + ScreenshotCommandOptions, + SnapshotCommandOptions, +} from './runtime-types.ts'; import { screenshotCommand, type ScreenshotCommandResult } from './capture-screenshot.ts'; import { diffScreenshotCommand, @@ -279,33 +285,14 @@ export type { CommandRouterResult, } from './router.ts'; -export type CommandResult = Record; -export type RuntimeCommand, TResult = CommandResult> = ( - runtime: AgentDeviceRuntime, - options: TOptions, -) => Promise; -export type BoundRuntimeCommand, TResult = CommandResult> = ( - options: TOptions, -) => Promise; - -export type ScreenshotCommandOptions = CommandContext & { - out?: FileOutputRef; - fullscreen?: boolean; - overlayRefs?: boolean; - appId?: string; - appBundleId?: string; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; -}; - -export type SnapshotCommandOptions = CommandContext & { - interactiveOnly?: boolean; - compact?: boolean; - depth?: number; - scope?: string; - raw?: boolean; -}; - -export type DiffSnapshotCommandOptions = SnapshotCommandOptions; +export type { + BoundRuntimeCommand, + CommandResult, + DiffSnapshotCommandOptions, + RuntimeCommand, + ScreenshotCommandOptions, + SnapshotCommandOptions, +} from './runtime-types.ts'; export type AgentDeviceCommands = { capture: { diff --git a/src/commands/interaction-gestures.ts b/src/commands/interaction-gestures.ts index 838bd17a2..02912fe54 100644 --- a/src/commands/interaction-gestures.ts +++ b/src/commands/interaction-gestures.ts @@ -1,11 +1,11 @@ import { AppError } from '../utils/errors.ts'; import type { Point, Rect, SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; import { centerOfRect } from '../utils/snapshot.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import { requireIntInRange } from '../utils/validation.ts'; import { successText } from '../utils/success-text.ts'; import { isNodeVisibleInEffectiveViewport } from '../utils/mobile-snapshot-semantics.ts'; -import type { RuntimeCommand } from './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { assertSupportedInteractionSurface, captureInteractionSnapshot, diff --git a/src/commands/interaction-resolution.ts b/src/commands/interaction-resolution.ts index 91478b7e7..5cbe9b8ab 100644 --- a/src/commands/interaction-resolution.ts +++ b/src/commands/interaction-resolution.ts @@ -1,7 +1,7 @@ import { AppError } from '../utils/errors.ts'; import type { Point, SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; import { centerOfRect, findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import { formatSelectorFailure, parseSelectorChain, resolveSelectorChain } from '../selectors.ts'; import { buildSelectorChainForNode } from '../utils/selector-build.ts'; import { findNodeByLabel, resolveRefLabel } from '../utils/snapshot-processing.ts'; diff --git a/src/commands/interactions.ts b/src/commands/interactions.ts index 3f0955e35..cbbd01bf9 100644 --- a/src/commands/interactions.ts +++ b/src/commands/interactions.ts @@ -1,11 +1,12 @@ import { AppError } from '../utils/errors.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { ClickButton } from '../core/click-button.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import { isFillableType } from '../utils/snapshot-processing.ts'; import { requireIntInRange } from '../utils/validation.ts'; import { successText } from '../utils/success-text.ts'; import type { ResolvedTarget } from './selector-read.ts'; import { toBackendContext } from './selector-read-utils.ts'; -import type { RuntimeCommand } from './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { type InteractionTarget, type ResolvedInteractionTarget, @@ -42,7 +43,7 @@ export type { export type PressCommandOptions = CommandContext & { target: InteractionTarget; - button?: 'primary' | 'secondary' | 'middle'; + button?: ClickButton; count?: number; intervalMs?: number; holdMs?: number; diff --git a/src/commands/io-policy.ts b/src/commands/io-policy.ts index e08e79bbd..83be18ce8 100644 --- a/src/commands/io-policy.ts +++ b/src/commands/io-policy.ts @@ -8,7 +8,7 @@ import type { ResolveInputOptions, TemporaryFile, } from '../io.ts'; -import type { AgentDeviceRuntime } from '../runtime.ts'; +import type { AgentDeviceRuntime } from '../runtime-contract.ts'; import { AppError, asAppError } from '../utils/errors.ts'; export async function resolveCommandInput( diff --git a/src/commands/recording.ts b/src/commands/recording.ts index 1652ae01d..ae3a96645 100644 --- a/src/commands/recording.ts +++ b/src/commands/recording.ts @@ -5,11 +5,11 @@ import type { BackendTraceResult, } from '../backend.ts'; import type { ArtifactDescriptor, FileOutputRef } from '../io.ts'; -import type { CommandContext } from '../runtime.ts'; +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 './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { reserveCommandOutput } from './io-policy.ts'; import { toBackendContext } from './selector-read-utils.ts'; diff --git a/src/commands/router-orchestration.ts b/src/commands/router-orchestration.ts index 6d82b9d8f..00b47eb8d 100644 --- a/src/commands/router-orchestration.ts +++ b/src/commands/router-orchestration.ts @@ -1,37 +1,17 @@ -import type { CommandContext } from '../runtime.ts'; -import { AppError, type NormalizedError } from '../utils/errors.ts'; -import type { CommandRouter, CommandRouterRequest, CommandRouterResult } from './router-types.ts'; +import type { CommandContext } from '../runtime-contract.ts'; +import { AppError } from '../utils/errors.ts'; +import type { + BatchCommandResult, + BatchCommandStepResult, + CommandRouter, + CommandRouterRequest, +} from './router-types.ts'; -export type BatchCommandOptions = CommandContext & { - steps: readonly CommandRouterRequest[]; - stopOnError?: boolean; - maxSteps?: number; -}; - -export type BatchCommandStepResult = - | { - step: number; - command: string; - ok: true; - data: CommandRouterResult; - durationMs: number; - } - | { - step: number; - command: string; - ok: false; - error: NormalizedError; - durationMs: number; - }; - -export type BatchCommandResult = { - kind: 'batch'; - total: number; - executed: number; - failed: number; - totalDurationMs: number; - results: readonly BatchCommandStepResult[]; -}; +export type { + BatchCommandOptions, + BatchCommandResult, + BatchCommandStepResult, +} from './router-types.ts'; const ROUTER_BATCH_MAX_STEPS = 50; diff --git a/src/commands/router-types.ts b/src/commands/router-types.ts index 442d950cf..92bc9a88b 100644 --- a/src/commands/router-types.ts +++ b/src/commands/router-types.ts @@ -1,4 +1,4 @@ -import type { AgentDeviceRuntime } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import type { NormalizedError } from '../utils/errors.ts'; import type { ScreenshotCommandResult } from './capture-screenshot.ts'; import type { @@ -97,8 +97,7 @@ import type { DiffSnapshotCommandOptions, ScreenshotCommandOptions, SnapshotCommandOptions, -} from './index.ts'; -import type { BatchCommandOptions, BatchCommandResult } from './router-orchestration.ts'; +} from './runtime-types.ts'; export type CommandRouterRequest = | { command: 'capture.screenshot'; options: ScreenshotCommandOptions; context?: TContext } @@ -232,3 +231,34 @@ export type CommandRouterConfig = { beforeDispatch?(request: CommandRouterRequest): void | Promise; formatError?(error: unknown, request: CommandRouterRequest): NormalizedError; }; + +export type BatchCommandOptions = CommandContext & { + steps: readonly CommandRouterRequest[]; + stopOnError?: boolean; + maxSteps?: number; +}; + +export type BatchCommandStepResult = + | { + step: number; + command: string; + ok: true; + data: CommandRouterResult; + durationMs: number; + } + | { + step: number; + command: string; + ok: false; + error: NormalizedError; + durationMs: number; + }; + +export type BatchCommandResult = { + kind: 'batch'; + total: number; + executed: number; + failed: number; + totalDurationMs: number; + results: readonly BatchCommandStepResult[]; +}; diff --git a/src/commands/router.ts b/src/commands/router.ts index aea1993a4..fc2c0969f 100644 --- a/src/commands/router.ts +++ b/src/commands/router.ts @@ -1,4 +1,4 @@ -import type { AgentDeviceRuntime } from '../runtime.ts'; +import type { AgentDeviceRuntime } from '../runtime-contract.ts'; import { AppError, normalizeAgentDeviceError } from '../utils/errors.ts'; import { screenshotCommand } from './capture-screenshot.ts'; import { diffScreenshotCommand } from './capture-diff-screenshot.ts'; diff --git a/src/commands/runtime-types.ts b/src/commands/runtime-types.ts new file mode 100644 index 000000000..61dc80f93 --- /dev/null +++ b/src/commands/runtime-types.ts @@ -0,0 +1,32 @@ +import type { FileOutputRef } from '../io.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; + +export type CommandResult = Record; + +export type RuntimeCommand, TResult = CommandResult> = ( + runtime: AgentDeviceRuntime, + options: TOptions, +) => Promise; + +export type BoundRuntimeCommand, TResult = CommandResult> = ( + options: TOptions, +) => Promise; + +export type ScreenshotCommandOptions = CommandContext & { + out?: FileOutputRef; + fullscreen?: boolean; + overlayRefs?: boolean; + appId?: string; + appBundleId?: string; + surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; +}; + +export type SnapshotCommandOptions = CommandContext & { + interactiveOnly?: boolean; + compact?: boolean; + depth?: number; + scope?: string; + raw?: boolean; +}; + +export type DiffSnapshotCommandOptions = SnapshotCommandOptions; diff --git a/src/commands/selector-read-shared.ts b/src/commands/selector-read-shared.ts index e9b10f667..a66d29130 100644 --- a/src/commands/selector-read-shared.ts +++ b/src/commands/selector-read-shared.ts @@ -1,4 +1,4 @@ -import type { AgentDeviceRuntime, CommandContext, CommandSessionRecord } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext, CommandSessionRecord } from '../runtime-contract.ts'; import { AppError } from '../utils/errors.ts'; import type { SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; diff --git a/src/commands/selector-read-utils.ts b/src/commands/selector-read-utils.ts index 254bb54ba..31cd72c9d 100644 --- a/src/commands/selector-read-utils.ts +++ b/src/commands/selector-read-utils.ts @@ -1,5 +1,5 @@ import type { BackendCommandContext } from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; export { findNodeByLabel, resolveRefLabel } from '../utils/snapshot-processing.ts'; diff --git a/src/commands/selector-read.ts b/src/commands/selector-read.ts index 8c29056bd..b8443d27f 100644 --- a/src/commands/selector-read.ts +++ b/src/commands/selector-read.ts @@ -2,7 +2,7 @@ import type { FindAction, FindLocator } from '../utils/finders.ts'; import { findBestMatchesByLocator } from '../utils/finders.ts'; import type { SnapshotNode } from '../utils/snapshot.ts'; import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; import { AppError } from '../utils/errors.ts'; import { findSelectorChainMatch, @@ -12,7 +12,7 @@ import { } from '../selectors.ts'; import { buildSelectorChainForNode } from '../utils/selector-build.ts'; import { evaluateIsPredicate, isSupportedPredicate } from '../utils/selector-is-predicates.ts'; -import type { RuntimeCommand } from './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { type CapturedSnapshot, type SelectorSnapshotOptions, diff --git a/src/commands/system.ts b/src/commands/system.ts index cf98b7079..eeefe9b17 100644 --- a/src/commands/system.ts +++ b/src/commands/system.ts @@ -5,11 +5,11 @@ import type { BackendDeviceOrientation, BackendKeyboardResult, } from '../backend.ts'; -import type { CommandContext } from '../runtime.ts'; +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 './index.ts'; +import type { RuntimeCommand } from './runtime-types.ts'; import { toBackendContext } from './selector-read-utils.ts'; export type SystemBackCommandOptions = CommandContext & { diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 60d945491..545dea379 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -136,21 +136,15 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise = [ @@ -24,16 +25,11 @@ export function shouldUseIosTapSeries( holdMs: number, jitterPx: number, ): boolean { - return ( - (device.platform === 'ios' || device.platform === 'macos') && - count > 1 && - holdMs === 0 && - jitterPx === 0 - ); + return isApplePlatform(device.platform) && count > 1 && holdMs === 0 && jitterPx === 0; } export function shouldUseIosDragSeries(device: DeviceInfo, count: number): boolean { - return (device.platform === 'ios' || device.platform === 'macos') && count > 1; + return isApplePlatform(device.platform) && count > 1; } export function computeDeterministicJitter(index: number, jitterPx: number): [number, number] { @@ -54,7 +50,3 @@ export async function runRepeatedSeries( } } } - -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 79c9b67c1..a161286b6 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -13,11 +13,15 @@ import { getInteractor, type Interactor, type RunnerContext } from './interactor import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; import { runMacOsPressAction, runMacOsReadTextAction } from '../platforms/ios/macos-helper.ts'; import { pushIosNotification } from '../platforms/ios/index.ts'; -import { snapshotLinux } from '../platforms/linux/index.ts'; +import { snapshotLinux } from '../platforms/linux/snapshot.ts'; import { rightClickLinux, middleClickLinux } from '../platforms/linux/input-actions.ts'; import type { SessionSurface } from './session-surface.ts'; import { isDeepLinkTarget } from './open-target.ts'; -import { getClickButtonValidationError, resolveClickButton } from './click-button.ts'; +import { + getClickButtonValidationError, + resolveClickButton, + type ClickButton, +} from './click-button.ts'; import { parseTriggerAppEventArgs, resolveAppEventUrl } from './app-events.ts'; import type { RawSnapshotNode } from '../utils/snapshot.ts'; import type { CliFlags } from '../utils/command-schema.ts'; @@ -69,7 +73,7 @@ type DispatchContext = { jitterPx?: number; pixels?: number; doubleTap?: boolean; - clickButton?: 'primary' | 'secondary' | 'middle'; + clickButton?: ClickButton; backMode?: 'in-app' | 'system'; pauseMs?: number; pattern?: 'one-way' | 'ping-pong'; @@ -879,11 +883,7 @@ function findMistargetedTypeRef(positionals: string[]): string | null { return null; } -function formatPressMessage(params: { - x: number; - y: number; - button?: 'primary' | 'secondary' | 'middle'; -}): string { +function formatPressMessage(params: { x: number; y: number; button?: ClickButton }): string { if (params.button && params.button !== 'primary') { return `Clicked ${params.button} (${params.x}, ${params.y})`; } diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts new file mode 100644 index 000000000..1ab72f154 --- /dev/null +++ b/src/core/interactor-types.ts @@ -0,0 +1,64 @@ +import type { DeviceRotation } from './device-rotation.ts'; +import type { ScrollDirection } from './scroll-gesture.ts'; +import type { PermissionSettingOptions } from '../platforms/permission-utils.ts'; +import type { SessionSurface } from './session-surface.ts'; + +export type RunnerContext = { + requestId?: string; + appBundleId?: string; + verbose?: boolean; + logPath?: string; + traceLogPath?: string; +}; + +export type BackMode = 'in-app' | 'system'; + +export type ScreenshotOptions = { + appBundleId?: string; + fullscreen?: boolean; + surface?: SessionSurface; +}; + +export type Interactor = { + open( + app: string, + options?: { activity?: string; appBundleId?: string; url?: string }, + ): Promise; + openDevice(): Promise; + close(app: string): Promise; + tap(x: number, y: number): Promise | void>; + doubleTap(x: number, y: number): Promise | void>; + swipe( + x1: number, + y1: number, + x2: number, + y2: number, + durationMs?: number, + ): Promise | void>; + longPress(x: number, y: number, durationMs?: number): Promise | void>; + focus(x: number, y: number): Promise | void>; + type(text: string, delayMs?: number): Promise; + fill( + x: number, + y: number, + text: string, + delayMs?: number, + ): Promise | void>; + scroll( + direction: ScrollDirection, + options?: { amount?: number; pixels?: number }, + ): Promise | void>; + screenshot(outPath: string, options?: ScreenshotOptions): Promise; + back(mode?: BackMode): Promise; + home(): Promise; + rotate(orientation: DeviceRotation): Promise; + appSwitcher(): Promise; + readClipboard(): Promise; + writeClipboard(text: string): Promise; + setSetting( + setting: string, + state: string, + appId?: string, + options?: PermissionSettingOptions, + ): Promise | void>; +}; diff --git a/src/core/interactors.ts b/src/core/interactors.ts index 5d240dafb..14db15022 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -1,7 +1,5 @@ import { AppError } from '../utils/errors.ts'; import type { DeviceInfo } from '../utils/device.ts'; -import type { DeviceRotation } from './device-rotation.ts'; -import type { ScrollDirection } from './scroll-gesture.ts'; import { appSwitcherAndroid, backAndroid, @@ -55,68 +53,14 @@ import { homeLinux, } from '../platforms/linux/app-lifecycle.ts'; import { readLinuxClipboard, writeLinuxClipboard } from '../platforms/linux/clipboard.ts'; -import type { PermissionSettingOptions } from '../platforms/permission-utils.ts'; -import type { SessionSurface } from './session-surface.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; -export type RunnerContext = { - requestId?: string; - appBundleId?: string; - verbose?: boolean; - logPath?: string; - traceLogPath?: string; -}; - -export type BackMode = 'in-app' | 'system'; - -export type ScreenshotOptions = { - appBundleId?: string; - fullscreen?: boolean; - surface?: SessionSurface; -}; - -export type Interactor = { - open( - app: string, - options?: { activity?: string; appBundleId?: string; url?: string }, - ): Promise; - openDevice(): Promise; - close(app: string): Promise; - tap(x: number, y: number): Promise | void>; - doubleTap(x: number, y: number): Promise | void>; - swipe( - x1: number, - y1: number, - x2: number, - y2: number, - durationMs?: number, - ): Promise | void>; - longPress(x: number, y: number, durationMs?: number): Promise | void>; - focus(x: number, y: number): Promise | void>; - type(text: string, delayMs?: number): Promise; - fill( - x: number, - y: number, - text: string, - delayMs?: number, - ): Promise | void>; - scroll( - direction: ScrollDirection, - options?: { amount?: number; pixels?: number }, - ): Promise | void>; - screenshot(outPath: string, options?: ScreenshotOptions): Promise; - back(mode?: BackMode): Promise; - home(): Promise; - rotate(orientation: DeviceRotation): Promise; - appSwitcher(): Promise; - readClipboard(): Promise; - writeClipboard(text: string): Promise; - setSetting( - setting: string, - state: string, - appId?: string, - options?: PermissionSettingOptions, - ): Promise | void>; -}; +export type { + BackMode, + Interactor, + RunnerContext, + ScreenshotOptions, +} from './interactor-types.ts'; export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor { switch (device.platform) { diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 1ed0087fb..95093a368 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -3,7 +3,8 @@ import http from 'node:http'; import https from 'node:https'; import fs from 'node:fs'; import path from 'node:path'; -import { AppError } from './utils/errors.ts'; +import { sleep } from './utils/timeouts.ts'; +import { AppError, toAppErrorCode } from './utils/errors.ts'; import type { DaemonArtifact, DaemonRequest as SharedDaemonRequest, @@ -483,7 +484,7 @@ async function ensureDaemon(settings: DaemonClientSettings): Promise // Detached daemon startup can race on busy CI hosts; retry when no metadata exists yet. if (!metadataState.hasInfo && !metadataState.hasLock) { - await sleepMs(150); + await sleep(150); continue; } } @@ -509,15 +510,11 @@ async function waitForDaemonInfo( while (Date.now() - start < timeoutMs) { const info = readDaemonInfo(settings.paths.infoPath); if (info && (await canConnect(info, settings.transportPreference))) return info; - await new Promise((resolve) => setTimeout(resolve, 100)); + await sleep(100); } return null; } -async function sleepMs(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - async function recoverDaemonLockHolder(paths: DaemonPaths): Promise { const state = getDaemonMetadataState(paths); if (!state.hasLock || state.hasInfo) return false; @@ -979,7 +976,10 @@ async function sendHttpRequest( const data = parsed.error.data ?? {}; reject( new AppError( - String(data.code ?? 'COMMAND_FAILED') as any, + toAppErrorCode( + data.code != null ? String(data.code) : undefined, + 'COMMAND_FAILED', + ), String(data.message ?? parsed.error.message ?? 'Daemon RPC request failed'), { ...(typeof data.details === 'object' && data.details ? data.details : {}), diff --git a/src/daemon-error.ts b/src/daemon-error.ts index da6192a5d..6d2188cc7 100644 --- a/src/daemon-error.ts +++ b/src/daemon-error.ts @@ -1,8 +1,8 @@ -import { AppError } from './utils/errors.ts'; +import { AppError, toAppErrorCode } from './utils/errors.ts'; import type { DaemonError } from './contracts.ts'; export function throwDaemonError(error: DaemonError): never { - throw new AppError(error.code as any, error.message, { + throw new AppError(toAppErrorCode(error.code), error.message, { ...(error.details ?? {}), hint: error.hint, diagnosticId: error.diagnosticId, diff --git a/src/daemon.ts b/src/daemon.ts index 8624c0ee2..1eb355d6f 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -91,9 +91,7 @@ async function start(): Promise { for (const server of servers) { try { server.close(() => {}); - } catch { - // ignore - } + } catch {} } removeInfo(infoPath); releaseDaemonLock(lockPath); diff --git a/src/daemon/android-snapshot-freshness.ts b/src/daemon/android-snapshot-freshness.ts index e0de8fc0d..3c23084f3 100644 --- a/src/daemon/android-snapshot-freshness.ts +++ b/src/daemon/android-snapshot-freshness.ts @@ -1,5 +1,7 @@ import type { SnapshotState } from '../utils/snapshot.ts'; -import type { SessionState } from './types.ts'; +import type { AndroidSnapshotFreshness, SessionState } from './types.ts'; + +export type { AndroidSnapshotFreshness } from './types.ts'; // How long after a navigation-sensitive action (press, click, back, open) to consider // the Android UI hierarchy potentially stale. Android's UIAutomator dump is async @@ -12,14 +14,6 @@ const ANDROID_FRESHNESS_WINDOW_MS = 2_500; // adding perceptible lag to the happy path. export const ANDROID_FRESHNESS_RETRY_DELAYS_MS = [250, 400] as const; -export type AndroidSnapshotFreshness = { - action: string; - markedAt: number; - baselineCount: number; - baselineSignatures?: string[]; - routeComparable: boolean; -}; - export type AndroidFreshnessCaptureMeta = { action: string; retryCount: number; diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index f6e798cd3..f11c462d4 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -3,6 +3,7 @@ import { adbArgs } from '../platforms/android/adb.ts'; import { runCmd } from '../utils/exec.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; import { centerOfRect, attachRefs, type SnapshotNode } from '../utils/snapshot.ts'; +import { sleep } from '../utils/timeouts.ts'; import { pruneGroupNodes } from './snapshot-processing.ts'; import type { SessionState } from './types.ts'; @@ -178,7 +179,3 @@ function containsBlockingDialog(nodes: SnapshotNode[]): boolean { return text.length > 0 && ANDROID_BLOCKING_MODAL_PATTERN.test(text); }); } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/daemon/app-log-android.ts b/src/daemon/app-log-android.ts index 1c63d1927..415a371a5 100644 --- a/src/daemon/app-log-android.ts +++ b/src/daemon/app-log-android.ts @@ -9,12 +9,8 @@ import { type AppLogResult, type AppLogState, } from './app-log-process.ts'; -import { - attachChildToStream, - createLineWriter, - sleep, - waitForChildExit, -} from './app-log-stream.ts'; +import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts'; +import { sleep } from '../utils/timeouts.ts'; export function assertAndroidPackageArgSafe(appBundleId: string): void { if (!/^[a-zA-Z0-9._:-]+$/.test(appBundleId)) { diff --git a/src/daemon/app-log-stream.ts b/src/daemon/app-log-stream.ts index 85c5c8baf..0f5aecefe 100644 --- a/src/daemon/app-log-stream.ts +++ b/src/daemon/app-log-stream.ts @@ -12,10 +12,6 @@ export async function waitForChildExit( ]); } -export async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - function redactChunk(chunk: string, patterns: RegExp[]): string { if (patterns.length === 0) return chunk; let output = chunk; diff --git a/src/daemon/context.ts b/src/daemon/context.ts index 8e9a68b07..ffc269a2f 100644 --- a/src/daemon/context.ts +++ b/src/daemon/context.ts @@ -1,5 +1,5 @@ import type { CommandFlags } from '../core/dispatch.ts'; -import { resolveClickButton } from '../core/click-button.ts'; +import { resolveClickButton, type ClickButton } from '../core/click-button.ts'; import type { SessionSurface } from '../core/session-surface.ts'; import { getDiagnosticsMeta } from '../utils/diagnostics.ts'; @@ -23,7 +23,7 @@ export type DaemonCommandContext = { jitterPx?: number; pixels?: number; doubleTap?: boolean; - clickButton?: 'primary' | 'secondary' | 'middle'; + clickButton?: ClickButton; backMode?: 'in-app' | 'system'; pauseMs?: number; pattern?: 'one-way' | 'ping-pong'; diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index aafbdcebf..2f99b1c2f 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -1,4 +1,5 @@ import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts'; +import { sleep } from '../../utils/timeouts.ts'; import { findBestMatchesByLocator, parseFindArgs, type FindLocator } from '../../utils/finders.ts'; import { centerOfRect, type SnapshotState } from '../../utils/snapshot.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; @@ -200,7 +201,7 @@ async function handleFindWait( } return { ok: true, data: { found: true, waitedMs: Date.now() - start } }; } - await new Promise((resolve) => setTimeout(resolve, 300)); + await sleep(300); } return errorResponse('COMMAND_FAILED', 'find wait timed out'); } diff --git a/src/daemon/handlers/interaction-get.ts b/src/daemon/handlers/interaction-get.ts deleted file mode 100644 index 5b5ff8dc9..000000000 --- a/src/daemon/handlers/interaction-get.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { buildSelectorChainForNode } from '../selectors.ts'; -import type { DaemonResponse } from '../types.ts'; -import type { InteractionHandlerParams } from './interaction-common.ts'; -import { refSnapshotFlagGuardResponse } from './interaction-flags.ts'; -import { readTextForNode } from './interaction-read.ts'; -import { resolveRefTarget } from './interaction-targeting.ts'; -import { resolveSelectorTarget } from './interaction-selector.ts'; -import { errorResponse } from './response.ts'; - -export async function handleGetCommand(params: InteractionHandlerParams): Promise { - const { req, sessionName, sessionStore, contextFromFlags } = params; - const sub = req.positionals?.[0]; - if (sub !== 'text' && sub !== 'attrs') { - return errorResponse('INVALID_ARGS', 'get only supports text or attrs'); - } - const session = sessionStore.get(sessionName); - if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); - if (!isCommandSupportedOnDevice('get', session.device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'get is not supported on this device'); - } - const refInput = req.positionals?.[1] ?? ''; - if (refInput.startsWith('@')) { - const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('get', req.flags); - if (invalidRefFlagsResponse) return invalidRefFlagsResponse; - const labelCandidate = - req.positionals.length > 2 ? req.positionals.slice(2).join(' ').trim() : ''; - const resolvedRefTarget = resolveRefTarget({ - session, - refInput, - fallbackLabel: labelCandidate, - requireRect: false, - invalidRefMessage: 'get text requires a ref like @e2', - notFoundMessage: `Ref ${refInput} not found`, - }); - if (!resolvedRefTarget.ok) return resolvedRefTarget; - const { ref, node } = resolvedRefTarget.target; - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'get', - }); - if (sub === 'attrs') { - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { ref, selectorChain }, - }); - return { ok: true, data: { ref, node } }; - } - const text = await readTextForNode({ - device: session.device, - node, - flags: req.flags, - appBundleId: session.appBundleId, - traceOutPath: session.trace?.outPath, - surface: session.surface, - contextFromFlags, - }); - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { ref, text, refLabel: compactRecordedGetRefLabel(text), selectorChain }, - }); - return { ok: true, data: { ref, text, node } }; - } - - const selectorExpression = req.positionals.slice(1).join(' ').trim(); - if (!selectorExpression) { - return errorResponse('INVALID_ARGS', 'get requires @ref or selector expression'); - } - const resolvedSelectorTarget = await resolveSelectorTarget({ - command: req.command, - selectorExpression, - session, - flags: req.flags, - sessionStore, - contextFromFlags, - interactiveOnly: false, - requireRect: false, - requireUnique: true, - disambiguateAmbiguous: sub === 'text', - }); - if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget; - const { resolved } = resolvedSelectorTarget; - const node = resolved.node; - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'get', - }); - if (sub === 'attrs') { - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { selector: resolved.selector.raw, selectorChain }, - }); - return { ok: true, data: { selector: resolved.selector.raw, node } }; - } - const text = await readTextForNode({ - device: session.device, - node, - flags: req.flags, - appBundleId: session.appBundleId, - traceOutPath: session.trace?.outPath, - surface: session.surface, - contextFromFlags, - }); - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - text, - refLabel: compactRecordedGetRefLabel(text), - selector: resolved.selector.raw, - selectorChain, - }, - }); - return { ok: true, data: { selector: resolved.selector.raw, text, node } }; -} - -function compactRecordedGetRefLabel(text: string): string | undefined { - const trimmed = text.trim(); - if (!trimmed || trimmed.length > 80 || /[\r\n]/.test(trimmed)) { - return undefined; - } - return trimmed; -} diff --git a/src/daemon/handlers/interaction-is.ts b/src/daemon/handlers/interaction-is.ts deleted file mode 100644 index 031c3d278..000000000 --- a/src/daemon/handlers/interaction-is.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { evaluateIsPredicate, isSupportedPredicate } from '../is-predicates.ts'; -import { - findSelectorChainMatch, - formatSelectorFailure, - parseSelectorChain, - splitIsSelectorArgs, -} from '../selectors.ts'; -import type { DaemonResponse } from '../types.ts'; -import type { InteractionHandlerParams } from './interaction-common.ts'; -import { errorResponse } from './response.ts'; -import { captureSnapshotForSession } from './interaction-snapshot.ts'; -import { resolveSelectorTarget } from './interaction-selector.ts'; - -export async function handleIsCommand(params: InteractionHandlerParams): Promise { - const { req, sessionName, sessionStore, contextFromFlags } = params; - const predicate = (req.positionals?.[0] ?? '').toLowerCase(); - if (!isSupportedPredicate(predicate)) { - return errorResponse( - 'INVALID_ARGS', - 'is requires predicate: visible|hidden|exists|editable|selected|text', - ); - } - const session = sessionStore.get(sessionName); - if (!session) { - return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); - } - if (!isCommandSupportedOnDevice('is', session.device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'is is not supported on this device'); - } - const { split } = splitIsSelectorArgs(req.positionals); - if (!split) { - return errorResponse('INVALID_ARGS', 'is requires a selector expression'); - } - const expectedText = split.rest.join(' ').trim(); - if (predicate === 'text' && !expectedText) { - return errorResponse('INVALID_ARGS', 'is text requires expected text value'); - } - if (predicate !== 'text' && split.rest.length > 0) { - return errorResponse('INVALID_ARGS', `is ${predicate} does not accept trailing values`); - } - const chain = parseSelectorChain(split.selectorExpression); - if (predicate === 'exists') { - const snapshot = await captureSnapshotForSession( - session, - req.flags, - sessionStore, - contextFromFlags, - { interactiveOnly: false }, - ); - const matched = findSelectorChainMatch(snapshot.nodes, chain, { - platform: session.device.platform, - }); - if (!matched) { - return errorResponse('COMMAND_FAILED', formatSelectorFailure(chain, [], { unique: false })); - } - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - predicate, - selector: matched.selector.raw, - selectorChain: chain.selectors.map((entry) => entry.raw), - pass: true, - matches: matched.matches, - }, - }); - return { - ok: true, - data: { predicate, pass: true, selector: matched.selector.raw, matches: matched.matches }, - }; - } - - const resolvedSelectorTarget = await resolveSelectorTarget({ - command: 'is', - selectorExpression: split.selectorExpression, - session, - flags: req.flags, - sessionStore, - contextFromFlags, - interactiveOnly: false, - requireRect: false, - requireUnique: true, - disambiguateAmbiguous: false, - }); - if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget; - const { resolved } = resolvedSelectorTarget; - const result = evaluateIsPredicate({ - predicate, - node: resolved.node, - nodes: resolvedSelectorTarget.snapshot.nodes, - expectedText, - platform: session.device.platform, - }); - if (!result.pass) { - return errorResponse( - 'COMMAND_FAILED', - `is ${predicate} failed for selector ${resolved.selector.raw}: ${result.details}`, - ); - } - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - predicate, - selector: resolved.selector.raw, - selectorChain: chain.selectors.map((entry) => entry.raw), - pass: true, - text: predicate === 'text' ? result.actualText : undefined, - }, - }); - return { ok: true, data: { predicate, pass: true, selector: resolved.selector.raw } }; -} diff --git a/src/daemon/handlers/interaction-selector.ts b/src/daemon/handlers/interaction-selector.ts deleted file mode 100644 index 956f8a3cb..000000000 --- a/src/daemon/handlers/interaction-selector.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; -import { formatSelectorFailure, parseSelectorChain, resolveSelectorChain } from '../selectors.ts'; -import type { SessionState } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import { captureSnapshotForSession } from './interaction-snapshot.ts'; -import type { ContextFromFlags } from './interaction-common.ts'; -import type { CommandFlags } from '../../core/dispatch.ts'; -import type { DaemonFailureResponse } from './response.ts'; - -export async function resolveSelectorTarget(params: { - command: string; - selectorExpression: string; - session: SessionState; - flags: CommandFlags | undefined; - sessionStore: SessionStore; - contextFromFlags: ContextFromFlags; - interactiveOnly: boolean; - requireRect: boolean; - requireUnique: boolean; - disambiguateAmbiguous: boolean; -}): Promise< - | { - ok: true; - chain: ReturnType; - snapshot: Awaited>; - resolved: NonNullable>>; - } - | DaemonFailureResponse -> { - const { - command, - selectorExpression, - session, - flags, - sessionStore, - contextFromFlags, - interactiveOnly, - requireRect, - requireUnique, - disambiguateAmbiguous, - } = params; - const chain = parseSelectorChain(selectorExpression); - const snapshot = await captureSnapshotForSession(session, flags, sessionStore, contextFromFlags, { - interactiveOnly, - }); - const resolved = await withDiagnosticTimer( - 'selector_resolve', - () => - resolveSelectorChain(snapshot.nodes, chain, { - platform: session.device.platform, - requireRect, - requireUnique, - disambiguateAmbiguous, - }), - { command }, - ); - if (!resolved || (requireRect && !resolved.node.rect)) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, resolved?.diagnostics ?? [], { - unique: requireUnique, - }), - }, - }; - } - return { ok: true, chain, snapshot, resolved }; -} diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index 5589fa90a..f3d42f0dc 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -1,8 +1,9 @@ import fs from 'node:fs'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { sleep } from '../../utils/timeouts.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; -import type { RecordTraceDeps } from './record-trace-recording.ts'; +import type { RecordTraceDeps } from './record-trace-types.ts'; import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; import { errorResponse } from './response.ts'; @@ -67,7 +68,7 @@ async function waitForAndroidProcessExit( if (!(await isAndroidProcessRunning(deps, deviceId, pid))) { return true; } - await new Promise((resolve) => setTimeout(resolve, ANDROID_PROCESS_EXIT_POLL_MS)); + await sleep(ANDROID_PROCESS_EXIT_POLL_MS); } return !(await isAndroidProcessRunning(deps, deviceId, pid)); } @@ -96,7 +97,7 @@ async function waitForAndroidRemoteFileStability( stableCount = 0; } previousSize = currentSize; - await new Promise((resolve) => setTimeout(resolve, ANDROID_REMOTE_FILE_POLL_MS)); + await sleep(ANDROID_REMOTE_FILE_POLL_MS); } } @@ -128,7 +129,7 @@ async function waitForAndroidRecordingReady( return true; } - await new Promise((resolve) => setTimeout(resolve, ANDROID_REMOTE_FILE_POLL_MS)); + await sleep(ANDROID_REMOTE_FILE_POLL_MS); } return false; @@ -196,7 +197,7 @@ async function copyAndroidRecordingWithValidation(params: { } if (attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS - 1) { - await new Promise((resolve) => setTimeout(resolve, ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS)); + await sleep(ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS); } } diff --git a/src/daemon/handlers/record-trace-finalize.ts b/src/daemon/handlers/record-trace-finalize.ts index f311a7638..8274fa50b 100644 --- a/src/daemon/handlers/record-trace-finalize.ts +++ b/src/daemon/handlers/record-trace-finalize.ts @@ -1,7 +1,7 @@ import { persistRecordingTelemetry } from '../recording-telemetry.ts'; import { getRecordingOverlaySupportWarning } from '../../recording/overlay.ts'; import { formatRecordTraceError } from '../record-trace-errors.ts'; -import type { RecordTraceDeps } from './record-trace-recording.ts'; +import type { RecordTraceDeps } from './record-trace-types.ts'; type FinalizeRecordingOverlayParams = { recording: { diff --git a/src/daemon/handlers/record-trace-ios.ts b/src/daemon/handlers/record-trace-ios.ts index efe90b4b4..dd9de58c7 100644 --- a/src/daemon/handlers/record-trace-ios.ts +++ b/src/daemon/handlers/record-trace-ios.ts @@ -3,7 +3,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/ios/runner-client.ts'; import { formatRecordTraceError } from '../record-trace-errors.ts'; -import type { RecordTraceDeps, RecordingBase } from './record-trace-recording.ts'; +import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; import { errorResponse } from './response.ts'; diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 71704705b..0ff360e3e 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -1,16 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; +import { sleep } from '../../utils/timeouts.ts'; import { resolveTargetDevice, type CommandFlags } from '../../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { SessionStore } from '../session-store.ts'; -import type { - DaemonArtifact, - DaemonRequest, - DaemonResponse, - RecordingGestureEvent, - SessionState, -} from '../types.ts'; +import type { DaemonArtifact, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { runCmd, runCmdBackground } from '../../utils/exec.ts'; import { isPlayableVideo, waitForStableFile } from '../../utils/video.ts'; import { deriveRecordingTelemetryPath } from '../recording-telemetry.ts'; @@ -41,25 +36,9 @@ const RECORDING_MAX_QUALITY = 10; const LOCAL_RECORDING_READY_POLL_MS = 250; const LOCAL_RECORDING_READY_SETTLE_POLLS = 2; -export type RecordTraceDeps = { - runCmd: typeof runCmd; - runCmdBackground: typeof runCmdBackground; - runIosRunnerCommand: typeof runIosRunnerCommand; - waitForStableFile: typeof waitForStableFile; - isPlayableVideo: typeof isPlayableVideo; - trimRecordingStart: typeof trimRecordingStart; - resizeRecording: typeof resizeRecording; - overlayRecordingTouches: typeof overlayRecordingTouches; -}; - -export type RecordingBase = { - outPath: string; - clientOutPath?: string; - startedAt: number; - quality?: number; - showTouches: boolean; - gestureEvents: RecordingGestureEvent[]; -}; +import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; + +export type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; export function buildRecordTraceDeps(): RecordTraceDeps { return { @@ -103,7 +82,7 @@ async function waitForLocalRecordingSettleWindow(outPath: string): Promise setTimeout(resolve, LOCAL_RECORDING_READY_POLL_MS)); + await sleep(LOCAL_RECORDING_READY_POLL_MS); } return Date.now(); diff --git a/src/daemon/handlers/record-trace-types.ts b/src/daemon/handlers/record-trace-types.ts new file mode 100644 index 000000000..5b4f77ab5 --- /dev/null +++ b/src/daemon/handlers/record-trace-types.ts @@ -0,0 +1,29 @@ +import type { runCmd, runCmdBackground } from '../../utils/exec.ts'; +import type { isPlayableVideo, waitForStableFile } from '../../utils/video.ts'; +import type { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; +import type { + overlayRecordingTouches, + resizeRecording, + trimRecordingStart, +} from '../../recording/overlay.ts'; +import type { RecordingGestureEvent } from '../types.ts'; + +export type RecordTraceDeps = { + runCmd: typeof runCmd; + runCmdBackground: typeof runCmdBackground; + runIosRunnerCommand: typeof runIosRunnerCommand; + waitForStableFile: typeof waitForStableFile; + isPlayableVideo: typeof isPlayableVideo; + trimRecordingStart: typeof trimRecordingStart; + resizeRecording: typeof resizeRecording; + overlayRecordingTouches: typeof overlayRecordingTouches; +}; + +export type RecordingBase = { + outPath: string; + clientOutPath?: string; + startedAt: number; + quality?: number; + showTouches: boolean; + gestureEvents: RecordingGestureEvent[]; +}; diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index fca806229..4461800e3 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -1,7 +1,7 @@ import { normalizeError } from '../../utils/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; +import { isApplePlatform, type DeviceInfo } from '../../utils/device.ts'; import { runMacOsAlertAction } from '../../platforms/ios/macos-helper.ts'; import { dispatchCommand } from '../../core/dispatch.ts'; import { contextFromFlags } from '../context.ts'; @@ -109,7 +109,7 @@ export async function handleCloseCommand(params: { await stopAppLog(session.appLog); } if (req.positionals && req.positionals.length > 0) { - if (session.device.platform === 'ios' || session.device.platform === 'macos') { + if (isApplePlatform(session.device.platform)) { await stopAppleRunnerForClose(session); } await dispatchCommand(session.device, 'close', req.positionals, req.flags?.out, { @@ -117,7 +117,7 @@ export async function handleCloseCommand(params: { }); await settleIosSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS); } - if (session.device.platform === 'ios' || session.device.platform === 'macos') { + if (isApplePlatform(session.device.platform)) { // The targeted close path stops before dispatch to avoid runner/app races. // Stop again here so both plain and targeted closes end with the runner down. // macOS may no-op the second alert dismiss, but it keeps teardown symmetric with runner stop. diff --git a/src/daemon/handlers/session-runtime.ts b/src/daemon/handlers/session-runtime.ts index 09fe218ea..b8de2ab31 100644 --- a/src/daemon/handlers/session-runtime.ts +++ b/src/daemon/handlers/session-runtime.ts @@ -3,7 +3,11 @@ import type { DeviceInfo } from '../../utils/device.ts'; import type { CommandFlags } from '../../core/dispatch.ts'; import type { DaemonRequest, SessionRuntimeHints, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; +import { + clearRuntimeHintsFromApp, + hasRuntimeTransportHints, + trimRuntimeValue, +} from '../runtime-hints.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; const RUNTIME_HINT_FIELD_NAMES = [ @@ -22,11 +26,6 @@ export function countConfiguredRuntimeHints(runtime: SessionRuntimeHints | undef ).length; } -function trimRuntimeString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; -} - function normalizeRuntimeStringInput( value: unknown, fieldName: 'metroHost' | 'bundleUrl' | 'launchUrl', @@ -35,7 +34,7 @@ function normalizeRuntimeStringInput( if (typeof value !== 'string') { throw new AppError('INVALID_ARGS', `Invalid open runtime ${fieldName}: expected string.`); } - return trimRuntimeString(value); + return trimRuntimeValue(value); } function validateRuntimePort(port: number | undefined): number | undefined { @@ -93,10 +92,10 @@ export function buildRuntimeHints( ): SessionRuntimeHints { return { platform, - metroHost: trimRuntimeString(flags?.metroHost), + metroHost: trimRuntimeValue(flags?.metroHost), metroPort: validateRuntimePort(flags?.metroPort), - bundleUrl: trimRuntimeString(flags?.bundleUrl), - launchUrl: trimRuntimeString(flags?.launchUrl), + bundleUrl: trimRuntimeValue(flags?.bundleUrl), + launchUrl: trimRuntimeValue(flags?.launchUrl), }; } diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 4b8ea9d42..aa2a0fa46 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -1,6 +1,6 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { asAppError } from '../../utils/errors.ts'; -import { normalizePlatformSelector, type DeviceInfo } from '../../utils/device.ts'; +import { isApplePlatform, normalizePlatformSelector, type DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { ensureDeviceReady } from '../device-ready.ts'; @@ -49,8 +49,7 @@ async function handleAppStateCommand(params: { if (guard) return guard; const shouldUseSessionStateForApple = - (session?.device.platform === 'ios' || session?.device.platform === 'macos') && - selectorTargetsSessionDevice(flags, session); + isApplePlatform(session?.device.platform) && selectorTargetsSessionDevice(flags, session); const targetsIos = normalizedPlatform === 'ios'; const targetsMacOs = normalizedPlatform === 'macos'; diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 77a8bd8af..943ef5f2a 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -1,4 +1,5 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; +import { sleep } from '../../utils/timeouts.ts'; import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; import { runMacOsAlertAction } from '../../platforms/ios/macos-helper.ts'; import { AppError } from '../../utils/errors.ts'; @@ -54,7 +55,7 @@ export async function handleAlertCommand( } catch { // keep waiting } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + await sleep(POLL_INTERVAL_MS); } return errorResponse('COMMAND_FAILED', 'alert wait timed out'); } @@ -73,7 +74,7 @@ export async function handleAlertCommand( const msg = String((err as { message?: unknown })?.message ?? '').toLowerCase(); if (!msg.includes('alert not found') && !msg.includes('no alert')) break; } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + await sleep(POLL_INTERVAL_MS); } throw withAlertFallbackHint(lastError); } @@ -101,7 +102,7 @@ export async function handleAlertCommand( } catch { // keep waiting } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + await sleep(POLL_INTERVAL_MS); } return errorResponse('COMMAND_FAILED', 'alert wait timed out'); } @@ -132,7 +133,7 @@ export async function handleAlertCommand( const msg = String((err as { message?: unknown })?.message ?? '').toLowerCase(); if (!msg.includes('alert not found') && !msg.includes('no alert')) break; } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + await sleep(POLL_INTERVAL_MS); } // lastError is always set because ALERT_ACTION_RETRY_MS > 0 throw withAlertFallbackHint(lastError); diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 0611348d1..73fe7e57e 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -1,6 +1,7 @@ import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; +import { sleep } from '../../utils/timeouts.ts'; import { runMacOsSnapshotAction } from '../../platforms/ios/macos-helper.ts'; -import { snapshotLinux } from '../../platforms/linux/index.ts'; +import { snapshotLinux } from '../../platforms/linux/snapshot.ts'; import type { AndroidSnapshotAnalysis } from '../../platforms/android/ui-hierarchy.ts'; import { attachRefs, @@ -108,7 +109,7 @@ async function captureAndroidFreshnessAwareSnapshot( for (const delayMs of ANDROID_FRESHNESS_RETRY_DELAYS_MS) { if (!suspiciousReason) break; - await new Promise((resolve) => setTimeout(resolve, delayMs)); + await sleep(delayMs); latest = await captureSnapshotAttempt(params); retryCount += 1; suspiciousReason = getAndroidFreshnessReason(latest, freshness, params.flags); diff --git a/src/daemon/handlers/snapshot-wait.ts b/src/daemon/handlers/snapshot-wait.ts index fb4868921..64281cfde 100644 --- a/src/daemon/handlers/snapshot-wait.ts +++ b/src/daemon/handlers/snapshot-wait.ts @@ -1,20 +1,5 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; -import { isApplePlatform } from '../../utils/device.ts'; -import { findNodeByRef, normalizeRef } from '../../utils/snapshot.ts'; -import { findNodeByLabel, resolveRefLabel } from '../snapshot-processing.ts'; -import { SessionStore } from '../session-store.ts'; -import { - findSelectorChainMatch, - splitSelectorFromArgs, - tryParseSelectorChain, - type SelectorChain, -} from '../selectors.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { captureSnapshot } from './snapshot-capture.ts'; -import { recordIfSession } from './snapshot-session.ts'; -import { DEFAULT_TIMEOUT_MS, parseTimeout, POLL_INTERVAL_MS } from './parse-utils.ts'; -import { errorResponse, type DaemonFailureResponse } from './response.ts'; +import { splitSelectorFromArgs, tryParseSelectorChain, type SelectorChain } from '../selectors.ts'; +import { parseTimeout } from './parse-utils.ts'; export type WaitParsed = | { kind: 'sleep'; durationMs: number } @@ -63,215 +48,6 @@ export function parseWaitArgs(args: string[]): WaitParsed | null { return { kind: 'text', text: text.trim(), timeoutMs }; } -type HandleWaitCommandParams = { - parsed: WaitParsed; - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - session: SessionState | undefined; - device: SessionState['device']; -}; - export function waitNeedsRunnerCleanup(parsed: WaitParsed): boolean { return parsed.kind !== 'sleep'; } - -export async function handleWaitCommand(params: HandleWaitCommandParams): Promise { - const { parsed, req, sessionName, logPath, sessionStore, session, device } = params; - if (parsed.kind === 'sleep') { - await new Promise((resolve) => setTimeout(resolve, parsed.durationMs)); - recordIfSession(sessionStore, session, req, { waitedMs: parsed.durationMs }); - return { ok: true, data: { waitedMs: parsed.durationMs } }; - } - if (!isCommandSupportedOnDevice('wait', device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'wait is not supported on this device'); - } - - if (parsed.kind === 'selector') { - return await waitForSelector({ - device, - logPath, - parsed, - req, - session, - sessionName, - sessionStore, - }); - } - - const textResult = resolveWaitText(parsed, session); - if (!textResult.ok) return textResult; - return await waitForText({ - device, - logPath, - req, - session, - sessionStore, - text: textResult.text, - timeoutMs: textResult.timeoutMs, - }); -} - -async function waitForSelector(params: { - device: SessionState['device']; - logPath: string; - parsed: Extract; - req: DaemonRequest; - session: SessionState | undefined; - sessionName: string; - sessionStore: SessionStore; -}): Promise { - const { device, logPath, parsed, req, session, sessionName, sessionStore } = params; - const timeout = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const start = Date.now(); - while (Date.now() - start < timeout) { - const snapshot = await captureWaitSnapshot({ - device, - logPath, - req, - session, - sessionName, - sessionStore, - }); - const match = findSelectorChainMatch(snapshot.nodes, parsed.selector, { - platform: device.platform, - }); - if (match) { - return waitSuccess(sessionStore, session, req, { - selector: match.selector.raw, - waitedMs: Date.now() - start, - }); - } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - return errorResponse( - 'COMMAND_FAILED', - `wait timed out for selector: ${parsed.selectorExpression}`, - ); -} - -function resolveWaitText( - parsed: Exclude, - session: SessionState | undefined, -): { ok: true; text: string; timeoutMs: number | null } | DaemonFailureResponse { - if (parsed.kind === 'ref') { - if (!session?.snapshot) { - return errorResponse('INVALID_ARGS', 'Ref wait requires an existing snapshot in session.'); - } - const ref = normalizeRef(parsed.rawRef); - if (!ref) { - return errorResponse('INVALID_ARGS', `Invalid ref: ${parsed.rawRef}`); - } - const node = findNodeByRef(session.snapshot.nodes, ref); - const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined; - if (!resolved) { - return errorResponse('COMMAND_FAILED', `Ref ${parsed.rawRef} not found or has no label`); - } - return { ok: true, text: resolved, timeoutMs: parsed.timeoutMs }; - } - - if (!parsed.text) { - return errorResponse('INVALID_ARGS', 'wait requires text'); - } - return { ok: true, text: parsed.text, timeoutMs: parsed.timeoutMs }; -} - -async function waitForText(params: { - device: SessionState['device']; - logPath: string; - req: DaemonRequest; - session: SessionState | undefined; - sessionStore: SessionStore; - text: string; - timeoutMs: number | null; -}): Promise { - const { device, logPath, req, session, sessionStore, text, timeoutMs } = params; - const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; - const start = Date.now(); - while (Date.now() - start < timeout) { - if (device.platform === 'macos' && session?.surface && session.surface !== 'app') { - const snapshot = await captureWaitSnapshot({ - device, - logPath, - req, - session, - sessionName: session?.name ?? req.session ?? 'default', - sessionStore, - }); - if (findNodeByLabel(snapshot.nodes, text)) { - return waitSuccess(sessionStore, session, req, { - text, - waitedMs: Date.now() - start, - }); - } - } else if (isApplePlatform(device.platform)) { - const result = (await runIosRunnerCommand( - device, - { command: 'findText', text, appBundleId: session?.appBundleId }, - { - verbose: req.flags?.verbose, - logPath, - traceLogPath: session?.trace?.outPath, - requestId: req.meta?.requestId, - }, - )) as { found?: boolean }; - if (result?.found) { - recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start }); - return { ok: true, data: { text, waitedMs: Date.now() - start } }; - } - } else if (device.platform === 'android') { - const snapshot = await captureWaitSnapshot({ - device, - logPath, - req, - session, - sessionName: session?.name ?? req.session ?? 'default', - sessionStore, - }); - if (findNodeByLabel(snapshot.nodes, text)) { - recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start }); - return { ok: true, data: { text, waitedMs: Date.now() - start } }; - } - } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - return errorResponse('COMMAND_FAILED', `wait timed out for text: ${text}`); -} - -async function captureWaitSnapshot(params: { - device: SessionState['device']; - logPath: string; - req: DaemonRequest; - session: SessionState | undefined; - sessionName: string; - sessionStore: SessionStore; -}): Promise { - const { device, logPath, req, session, sessionName, sessionStore } = params; - const { snapshot } = await captureSnapshot({ - device, - session, - flags: { - ...req.flags, - snapshotInteractiveOnly: false, - snapshotCompact: false, - }, - outPath: req.flags?.out, - logPath, - }); - if (session) { - session.snapshot = snapshot; - sessionStore.set(sessionName, session); - } - return snapshot; -} - -function waitSuccess( - sessionStore: SessionStore, - session: SessionState | undefined, - req: DaemonRequest, - data: Record, -): DaemonResponse { - recordIfSession(sessionStore, session, req, data); - return { ok: true, data }; -} diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 0d21f8027..069d71acc 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -1,6 +1,7 @@ import http, { type IncomingHttpHeaders } from 'node:http'; import fs from 'node:fs'; -import { AppError, normalizeError } from '../utils/errors.ts'; +import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts'; +import type { JsonRpcId, JsonRpcRequestEnvelope } from '../contracts.ts'; import type { DaemonInstallSource, DaemonRequest, DaemonResponse } from './types.ts'; import { normalizeTenantId } from './config.ts'; import { @@ -18,16 +19,11 @@ import { } from './artifact-tracking.ts'; import { receiveUpload } from './upload.ts'; -type JsonRpcRequest = { - jsonrpc?: string; - id?: string | number | null; - method?: string; - params?: unknown; -}; +type JsonRpcRequest = JsonRpcRequestEnvelope; type JsonRpcResponse = { jsonrpc: '2.0'; - id: string | number | null; + id: JsonRpcId; result?: unknown; error?: { code: number; @@ -90,7 +86,7 @@ const SUPPORTED_RPC_METHODS = new Set([ ]); function createRpcError( - id: string | number | null, + id: JsonRpcId, code: number, message: string, data?: Record, @@ -329,7 +325,7 @@ async function runHttpAuthHook( if (result.ok === false) { const normalized = normalizeError( new AppError( - (result.code as any) ?? 'UNAUTHORIZED', + toAppErrorCode(result.code, 'UNAUTHORIZED'), result.message ?? 'Request rejected by auth hook', result.details, ), diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index c4d916108..d3a6d0112 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import type { CommandFlags } from '../core/dispatch.ts'; import { dispatchCommand, resolveTargetDevice } from '../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../core/capabilities.ts'; -import { AppError, normalizeError } from '../utils/errors.ts'; +import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts'; import type { DaemonArtifact, DaemonRequest, @@ -156,7 +156,7 @@ function finalizeDaemonResponse( }); const logPathOnFailure = flushDiagnosticsToSessionFile({ force: true }) ?? undefined; const normalizedError = normalizeError( - new AppError(response.error.code as any, response.error.message, { + new AppError(toAppErrorCode(response.error.code), response.error.message, { ...(response.error.details ?? {}), hint: response.error.hint, diagnosticId: response.error.diagnosticId, diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index cea2972a5..c8d3455e3 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -296,7 +296,7 @@ function removeAndroidPrefEntry(xml: string, key: string): string { ); } -function trimRuntimeValue(value: string | undefined): string | undefined { +export function trimRuntimeValue(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } diff --git a/src/daemon/server-lifecycle.ts b/src/daemon/server-lifecycle.ts index 7cbd3996c..caeab928a 100644 --- a/src/daemon/server-lifecycle.ts +++ b/src/daemon/server-lifecycle.ts @@ -93,9 +93,7 @@ export function acquireDaemonLock( } try { fs.unlinkSync(lockPath); - } catch { - // ignore - } + } catch {} return tryWriteLock(); } @@ -104,9 +102,7 @@ export function releaseDaemonLock(lockPath: string): void { if (existing && existing.pid !== process.pid) return; try { if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); - } catch { - // ignore - } + } catch {} } export function parseIntegerEnv(raw: string | undefined): number | undefined { diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 01cd15ea6..2f0afda69 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -105,9 +105,7 @@ export class SessionStore { if (!fs.existsSync(scriptDir)) fs.mkdirSync(scriptDir, { recursive: true }); const script = formatScript(session, this.buildOptimizedActions(session)); fs.writeFileSync(scriptPath, script); - } catch { - // ignore - } + } catch {} } defaultTracePath(session: SessionState): string { diff --git a/src/daemon/transport.ts b/src/daemon/transport.ts index e344df29c..264811cf1 100644 --- a/src/daemon/transport.ts +++ b/src/daemon/transport.ts @@ -12,14 +12,11 @@ import { resolveRequestTrackingId, } from './request-cancel.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; +import { sleep } from '../utils/timeouts.ts'; const disconnectAbortPollIntervalMs = 200; const disconnectAbortMaxWindowMs = 15_000; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - export function createSocketServer( handleRequest: (req: DaemonRequest) => Promise, ): net.Server { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index b2395795f..0ad738585 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -14,7 +14,6 @@ import type { SessionSurface } from '../core/session-surface.ts'; import type { DeviceInfo, Platform, PlatformSelector } from '../utils/device.ts'; import type { ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../utils/snapshot.ts'; -import type { AndroidSnapshotFreshness } from './android-snapshot-freshness.ts'; import type { AppLogState } from './app-log-process.ts'; export type DaemonInstallSource = MaterializeInstallSource; @@ -130,6 +129,14 @@ export type RecordingGestureEvent = durationMs: number; }); +export type AndroidSnapshotFreshness = { + action: string; + markedAt: number; + baselineCount: number; + baselineSignatures?: string[]; + routeComparable: boolean; +}; + type SessionRecordingBase = { outPath: string; clientOutPath?: string; diff --git a/src/metro-types.ts b/src/metro-types.ts new file mode 100644 index 000000000..5ecce57d3 --- /dev/null +++ b/src/metro-types.ts @@ -0,0 +1,53 @@ +import type { SessionRuntimeHints } from './contracts.ts'; + +/** Re-export of {@link SessionRuntimeHints} under the Metro-specific alias used by public API consumers. */ +export type MetroRuntimeHints = SessionRuntimeHints; + +export type MetroBridgeResult = { + enabled: boolean; + baseUrl: string; + statusUrl: string; + bundleUrl: string; + iosRuntime: MetroRuntimeHints; + androidRuntime: MetroRuntimeHints; + upstream: { + bundleUrl: string; + host: string; + port: number; + statusUrl: string; + }; + probe: { + reachable: boolean; + statusCode: number; + latencyMs: number; + detail: string; + }; +}; + +export type MetroBridgeRuntimePayload = { + metro_host?: string; + metro_port?: number; + metro_bundle_url?: string; + launch_url?: string; +}; + +export type MetroBridgeDescriptor = { + enabled: boolean; + base_url: string; + status_url?: string; + bundle_url?: string; + ios_runtime: MetroBridgeRuntimePayload; + android_runtime: MetroBridgeRuntimePayload; + upstream: { + bundle_url?: string; + host?: string; + port?: number; + status_url?: string; + }; + probe: { + reachable: boolean; + status_code: number; + latency_ms: number; + detail: string; + }; +}; diff --git a/src/metro.ts b/src/metro.ts index 84ea1348b..a00da3508 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -1,13 +1,20 @@ import type { SessionRuntimeHints } from './contracts.ts'; import { buildMetroRuntimeHints, prepareMetroRuntime } from './client-metro.ts'; import { ensureMetroCompanion, stopMetroCompanion } from './client-metro-companion.ts'; +import type { MetroBridgeScope } from './client-metro-companion-contract.ts'; import { resolveRuntimeTransportHints } from './daemon/runtime-hints.ts'; export { buildBundleUrl, normalizeBaseUrl } from './utils/url.ts'; -type EnvSource = NodeJS.ProcessEnv | Record; +export type { + MetroBridgeDescriptor, + MetroBridgeResult, + MetroBridgeRuntimePayload, + MetroRuntimeHints, +} from './metro-types.ts'; + +import type { MetroBridgeResult, MetroRuntimeHints } from './metro-types.ts'; -/** Re-export of {@link SessionRuntimeHints} under the Metro-specific alias used by public API consumers. */ -export type MetroRuntimeHints = SessionRuntimeHints; +type EnvSource = NodeJS.ProcessEnv | Record; export function resolveRuntimeTransport( runtime: SessionRuntimeHints | undefined, @@ -15,55 +22,6 @@ export function resolveRuntimeTransport( return resolveRuntimeTransportHints(runtime); } -export type MetroBridgeResult = { - enabled: boolean; - baseUrl: string; - statusUrl: string; - bundleUrl: string; - iosRuntime: MetroRuntimeHints; - androidRuntime: MetroRuntimeHints; - upstream: { - bundleUrl: string; - host: string; - port: number; - statusUrl: string; - }; - probe: { - reachable: boolean; - statusCode: number; - latencyMs: number; - detail: string; - }; -}; - -export type MetroBridgeRuntimePayload = { - metro_host?: string; - metro_port?: number; - metro_bundle_url?: string; - launch_url?: string; -}; - -export type MetroBridgeDescriptor = { - enabled: boolean; - base_url: string; - status_url?: string; - bundle_url?: string; - ios_runtime: MetroBridgeRuntimePayload; - android_runtime: MetroBridgeRuntimePayload; - upstream: { - bundle_url?: string; - host?: string; - port?: number; - status_url?: string; - }; - probe: { - reachable: boolean; - status_code: number; - latency_ms: number; - detail: string; - }; -}; - export type MetroTunnelPingMessage = { type: 'ping'; timestamp: number; @@ -149,11 +107,7 @@ export type PrepareRemoteMetroOptions = { publicBaseUrl: string; proxyBaseUrl?: string; proxyBearerToken?: string; - bridgeScope?: { - tenantId: string; - runId: string; - leaseId: string; - }; + bridgeScope?: MetroBridgeScope; launchUrl?: string; profileKey?: string; consumerKey?: string; @@ -183,11 +137,7 @@ export type EnsureMetroTunnelOptions = { serverBaseUrl: string; bearerToken: string; localBaseUrl: string; - bridgeScope: { - tenantId: string; - runId: string; - leaseId: string; - }; + bridgeScope: MetroBridgeScope; launchUrl?: string; profileKey?: string; consumerKey?: string; diff --git a/src/platforms/android/adb.ts b/src/platforms/android/adb.ts index 85fefe0b1..fc72ba97c 100644 --- a/src/platforms/android/adb.ts +++ b/src/platforms/android/adb.ts @@ -3,6 +3,8 @@ import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { ensureAndroidSdkPathConfigured } from './sdk.ts'; +export { sleep } from '../../utils/timeouts.ts'; + export function adbArgs(device: DeviceInfo, args: string[]): string[] { return ['-s', device.id, ...args]; } @@ -19,7 +21,3 @@ export function isClipboardShellUnsupported(stdout: string, stderr: string): boo haystack.includes('no shell command implementation') || haystack.includes('unknown command') ); } - -export async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index 41bf13d26..5a69053d9 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -1,5 +1,6 @@ import { runCmd, runCmdDetached, whichCmd } from '../../utils/exec.ts'; import type { ExecResult } from '../../utils/exec.ts'; +import { sleep } from '../../utils/timeouts.ts'; import { AppError, asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { Deadline, retryWithPolicy, TIMEOUT_PROFILES } from '../../utils/retry.ts'; @@ -323,7 +324,7 @@ async function waitForAndroidEmulatorByAvdName(params: { } catch { // Best-effort polling while adb/emulator process settles. } - await new Promise((resolve) => setTimeout(resolve, ANDROID_EMULATOR_BOOT_POLL_MS)); + await sleep(ANDROID_EMULATOR_BOOT_POLL_MS); } throw new AppError('COMMAND_FAILED', 'Android emulator did not appear in time', { avdName: params.avdName, diff --git a/src/platforms/android/input-actions.ts b/src/platforms/android/input-actions.ts index 1a906647b..3c949faec 100644 --- a/src/platforms/android/input-actions.ts +++ b/src/platforms/android/input-actions.ts @@ -331,7 +331,8 @@ async function typeAndroidViaClipboard( function isAndroidInputTextUnsupported(error: unknown): boolean { if (!(error instanceof AppError)) return false; if (error.code !== 'COMMAND_FAILED') return false; - const stderr = String((error.details as any)?.stderr ?? '').toLowerCase(); + const rawStderr = error.details?.stderr; + const stderr = (typeof rawStderr === 'string' ? rawStderr : '').toLowerCase(); if (stderr.includes("exception occurred while executing 'text'")) return true; if (stderr.includes('nullpointerexception') && stderr.includes('inputshellcommand.sendtext')) return true; diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 4c9d7ddd1..2952220b8 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -112,7 +112,8 @@ function extractUiDumpXml(stdout: string, stderr: string): string | null { function isRetryableAdbError(err: unknown): boolean { if (!(err instanceof AppError)) return false; if (err.code !== 'COMMAND_FAILED') return false; - const stderr = `${(err.details as any)?.stderr ?? ''}`.toLowerCase(); + const rawStderr = err.details?.stderr; + const stderr = (typeof rawStderr === 'string' ? rawStderr : '').toLowerCase(); if (stderr.includes('device offline')) return true; if (stderr.includes('device not found')) return true; if (stderr.includes('transport error')) return true; diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index 79072b7f1..17e39fcdb 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -285,7 +285,7 @@ function shouldIncludeAndroidNode( const isVisual = type === 'imageview' || type === 'imagebutton'; if (options.interactiveOnly) { if (node.hittable) return true; - if (isScrollableContainerType(type) && descendantHittable) { + if (isScrollableType(type) && descendantHittable) { return true; } // Keep text proxies for tappable rows while dropping structural noise. @@ -317,10 +317,6 @@ function isCollectionContainerType(type: string | null): boolean { ); } -function isScrollableContainerType(type: string): boolean { - return isScrollableType(type); -} - function normalizeAndroidType(type: string | null): string { if (!type) return ''; return type.toLowerCase(); diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index 43984b720..f665da33c 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -2,7 +2,7 @@ import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-gesture.ts'; import { runIosRunnerCommand } from './runner-client.ts'; -import type { BackMode, Interactor, RunnerContext } from '../../core/interactors.ts'; +import type { BackMode, Interactor, RunnerContext } from '../../core/interactor-types.ts'; export type AppleBackRunnerCommand = 'backInApp' | 'backSystem'; type RunIosRunnerCommand = typeof runIosRunnerCommand; diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index bb6f742aa..73cd891bb 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -110,7 +110,6 @@ async function executeRunnerCommand( } } -// Re-export public API from submodules export { resolveRunnerDestination, resolveRunnerBuildDestination, diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index 14ad83c32..f4397c1b3 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -3,7 +3,7 @@ import type { ClickButton } from '../../core/click-button.ts'; import type { DeviceRotation } from '../../core/device-rotation.ts'; import { createRequestCanceledError, isRequestCanceled } from '../../daemon/request-cancel.ts'; import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; -import type { RunnerSession } from './runner-session.ts'; +import type { RunnerSession } from './runner-session-types.ts'; export type RunnerCommand = { command: diff --git a/src/platforms/ios/runner-session-types.ts b/src/platforms/ios/runner-session-types.ts new file mode 100644 index 000000000..e9773157a --- /dev/null +++ b/src/platforms/ios/runner-session-types.ts @@ -0,0 +1,15 @@ +import type { ExecResult, ExecBackgroundResult } from '../../utils/exec.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; + +export type RunnerSession = { + sessionId: string; + device: DeviceInfo; + deviceId: string; + port: number; + xctestrunPath: string; + jsonPath: string; + testPromise: Promise; + child: ExecBackgroundResult['child']; + ready: boolean; + simulatorSetRedirect?: { release: () => Promise }; +}; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 50dcd9535..15ce285a5 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -1,4 +1,4 @@ -import { AppError } from '../../utils/errors.ts'; +import { AppError, toAppErrorCode } from '../../utils/errors.ts'; import { runCmd, runCmdBackground, @@ -27,19 +27,9 @@ import { runnerPrepProcesses, } from './runner-xctestrun.ts'; import type { RunnerCommand } from './runner-contract.ts'; +import type { RunnerSession } from './runner-session-types.ts'; -export type RunnerSession = { - sessionId: string; - device: DeviceInfo; - deviceId: string; - port: number; - xctestrunPath: string; - jsonPath: string; - testPromise: Promise; - child: ExecBackgroundResult['child']; - ready: boolean; - simulatorSetRedirect?: { release: () => Promise }; -}; +export type { RunnerSession } from './runner-session-types.ts'; const runnerSessions = new Map(); const runnerSessionLocks = new Map>(); @@ -201,9 +191,7 @@ async function stopRunnerSessionInternal( session.testPromise, new Promise((resolve) => setTimeout(resolve, RUNNER_STOP_WAIT_TIMEOUT_MS)), ]); - } catch { - // ignore - } + } catch {} await killRunnerProcessTree(session.child.pid, 'SIGKILL'); cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); @@ -293,20 +281,14 @@ async function killRunnerProcessTree( if (!pid || pid <= 0) return; try { process.kill(-pid, signal); - } catch { - // ignore - } + } catch {} try { process.kill(pid, signal); - } catch { - // ignore - } + } catch {} const pkillSignal = signal === 'SIGINT' ? 'INT' : signal === 'SIGTERM' ? 'TERM' : 'KILL'; try { await runCmd('pkill', [`-${pkillSignal}`, '-P', String(pid)], { allowFailure: true }); - } catch { - // ignore - } + } catch {} } function ensureBootedIfNeeded(device: DeviceInfo): Promise { @@ -357,24 +339,33 @@ export async function executeRunnerCommandWithSession( return await parseRunnerResponse(response, session, logPath); } +type RunnerResponsePayload = { + ok?: unknown; + error?: { code?: unknown; message?: unknown }; + data?: unknown; +}; + export async function parseRunnerResponse( response: Response, session: RunnerSession, logPath?: string, ): Promise> { const text = await response.text(); - let json: any = {}; + let json: RunnerResponsePayload; try { - json = JSON.parse(text); + const parsed: unknown = JSON.parse(text); + json = parsed && typeof parsed === 'object' ? (parsed as RunnerResponsePayload) : {}; } catch { throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text }); } if (!json.ok) { + const rawCode = json.error?.code; const errorCode = - typeof json.error?.code === 'string' && json.error.code.trim().length > 0 - ? json.error.code + typeof rawCode === 'string' && rawCode.trim().length > 0 + ? toAppErrorCode(rawCode) : 'COMMAND_FAILED'; - throw new AppError(errorCode, json.error?.message ?? 'Runner error', { + const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined; + throw new AppError(errorCode, errorMessage ?? 'Runner error', { runner: json, xcodebuild: { exitCode: 1, @@ -385,5 +376,8 @@ export async function parseRunnerResponse( }); } session.ready = true; - return json.data ?? {}; + if (json.data && typeof json.data === 'object' && !Array.isArray(json.data)) { + return json.data as Record; + } + return {}; } diff --git a/src/platforms/ios/runner-transport.ts b/src/platforms/ios/runner-transport.ts index 30f8dfb60..0610fbd9e 100644 --- a/src/platforms/ios/runner-transport.ts +++ b/src/platforms/ios/runner-transport.ts @@ -16,7 +16,7 @@ import { shouldRetryRunnerConnectError, type RunnerCommand, } from './runner-contract.ts'; -import type { RunnerSession } from './runner-session.ts'; +import type { RunnerSession } from './runner-session-types.ts'; export const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs( process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS, @@ -333,7 +333,5 @@ export function logChunk( export function cleanupTempFile(filePath: string): void { try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch { - // ignore - } + } catch {} } diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index e1b51fe4e..40f2cff52 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { AppError } from '../../utils/errors.ts'; +import { sleep } from '../../utils/timeouts.ts'; import { runCmd, runCmdSync, @@ -338,7 +339,7 @@ async function acquireXcodebuildSimulatorSetLock(params: { if (clearStaleXcodebuildSimulatorSetLock(lockDirPath, ownerFilePath)) { continue; } - await new Promise((resolve) => setTimeout(resolve, XCTEST_DEVICE_SET_LOCK_POLL_MS)); + await sleep(XCTEST_DEVICE_SET_LOCK_POLL_MS); } } @@ -497,9 +498,7 @@ function cleanRunnerDerivedArtifacts(derived: string): void { if (!shouldDeleteRunnerDerivedRootEntry(entry.name)) continue; fs.rmSync(path.join(derived, entry.name), { recursive: true, force: true }); } - } catch { - // ignore - } + } catch {} } const RUNNER_ROOT_TRANSIENT_ENTRY_NAMES = new Set([ @@ -540,9 +539,7 @@ export function findXctestrun(root: string, device?: DeviceInfo): string | null try { const stat = fs.statSync(full); candidates.push({ path: full, mtimeMs: stat.mtimeMs }); - } catch { - // ignore - } + } catch {} } } } @@ -635,9 +632,7 @@ export function xctestrunReferencesProjectRoot( const candidateRoots = new Set([projectRoot]); try { candidateRoots.add(fs.realpathSync(projectRoot)); - } catch { - // ignore - } + } catch {} for (const root of candidateRoots) { if (contents.includes(root)) { return true; @@ -680,9 +675,31 @@ export async function prepareXctestrunWithEnv( }); } - let parsed: Record; + type EnvMap = Record; + type XctestrunTarget = { + TestBundlePath?: unknown; + EnvironmentVariables?: EnvMap; + UITestEnvironmentVariables?: EnvMap; + UITargetAppEnvironmentVariables?: EnvMap; + TestingEnvironmentVariables?: EnvMap; + [key: string]: unknown; + }; + type XctestrunConfig = { + TestTargets?: unknown; + [key: string]: unknown; + }; + type XctestrunPlist = { + TestConfigurations?: unknown; + [key: string]: unknown; + }; + + let parsed: XctestrunPlist; try { - parsed = JSON.parse(jsonResult.stdout) as Record; + const raw: unknown = JSON.parse(jsonResult.stdout); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Root must be an object'); + } + parsed = raw as XctestrunPlist; } catch (err) { throw new AppError('COMMAND_FAILED', 'Failed to parse xctestrun JSON', { xctestrunPath, @@ -690,7 +707,7 @@ export async function prepareXctestrunWithEnv( }); } - const applyEnvToTarget = (target: Record) => { + const applyEnvToTarget = (target: XctestrunTarget) => { target.EnvironmentVariables = { ...(target.EnvironmentVariables ?? {}), ...envVars }; target.UITestEnvironmentVariables = { ...(target.UITestEnvironmentVariables ?? {}), @@ -708,11 +725,11 @@ export async function prepareXctestrunWithEnv( const configs = parsed.TestConfigurations; if (Array.isArray(configs)) { - for (const config of configs) { + for (const config of configs as XctestrunConfig[]) { if (!config || typeof config !== 'object') continue; const targets = config.TestTargets; if (!Array.isArray(targets)) continue; - for (const target of targets) { + for (const target of targets as XctestrunTarget[]) { if (!target || typeof target !== 'object') continue; applyEnvToTarget(target); } @@ -720,9 +737,12 @@ export async function prepareXctestrunWithEnv( } for (const [key, value] of Object.entries(parsed)) { - if (value && typeof value === 'object' && value.TestBundlePath) { - applyEnvToTarget(value); - parsed[key] = value; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const candidate = value as XctestrunTarget; + if (candidate.TestBundlePath) { + applyEnvToTarget(candidate); + parsed[key] = candidate; + } } } diff --git a/src/platforms/linux/app-lifecycle.ts b/src/platforms/linux/app-lifecycle.ts index ef2c3556f..ff5150c17 100644 --- a/src/platforms/linux/app-lifecycle.ts +++ b/src/platforms/linux/app-lifecycle.ts @@ -1,5 +1,6 @@ import { runCmd, whichCmd } from '../../utils/exec.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { sleep } from '../../utils/timeouts.ts'; import { sendKey } from './input-actions.ts'; /** @@ -27,7 +28,7 @@ export async function openLinuxApp(app: string): Promise { }); }); // Give it a moment to start - await new Promise((resolve) => setTimeout(resolve, 500)); + await sleep(500); return; } diff --git a/src/platforms/linux/index.ts b/src/platforms/linux/index.ts deleted file mode 100644 index b47c1ce43..000000000 --- a/src/platforms/linux/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { listLinuxDevices } from './devices.ts'; -export { snapshotLinux } from './snapshot.ts'; -export { screenshotLinux } from './screenshot.ts'; -export { - pressLinux, - rightClickLinux, - middleClickLinux, - doubleClickLinux, - longPressLinux, - focusLinux, - swipeLinux, - scrollLinux, - typeLinux, - fillLinux, -} from './input-actions.ts'; -export { openLinuxApp, closeLinuxApp, backLinux, homeLinux } from './app-lifecycle.ts'; -export { readLinuxClipboard, writeLinuxClipboard } from './clipboard.ts'; diff --git a/src/platforms/linux/input-actions.ts b/src/platforms/linux/input-actions.ts index cd3dbc6eb..4d2b2c2f4 100644 --- a/src/platforms/linux/input-actions.ts +++ b/src/platforms/linux/input-actions.ts @@ -1,5 +1,6 @@ import { runCmd } from '../../utils/exec.ts'; import { ensureInputTool } from './linux-env.ts'; +import { sleep } from '../../utils/timeouts.ts'; import type { ScrollDirection } from '../../core/scroll-gesture.ts'; // ── Low-level wrappers ───────────────────────────────────────────────── @@ -185,9 +186,3 @@ export async function fillLinux(x: number, y: number, text: string, delayMs = 0) // Type replacement text await typeLinux(text, delayMs); } - -// ── Utilities ─────────────────────────────────────────────────────────── - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/runtime-contract.ts b/src/runtime-contract.ts new file mode 100644 index 000000000..9bd060587 --- /dev/null +++ b/src/runtime-contract.ts @@ -0,0 +1,70 @@ +import type { AgentDeviceBackend, BackendCapabilityName } from './backend.ts'; +import type { ArtifactAdapter } from './io.ts'; +import type { SnapshotState } from './utils/snapshot.ts'; + +export type CommandPolicy = { + allowLocalInputPaths: boolean; + allowLocalOutputPaths: boolean; + maxImagePixels: number; + allowNamedBackendCapabilities: readonly BackendCapabilityName[]; +}; + +export type CommandSessionRecord = { + name: string; + appId?: string; + appBundleId?: string; + appName?: string; + backendSessionId?: string; + snapshot?: SnapshotState; + metadata?: Record; +}; + +// Runtime commands can read and then write the same session. CommandSessionStore +// implementations that are shared across concurrent callers should serialize +// per-session updates, or route commands through a transport that already does. +export type CommandSessionStore = { + get(name: string): CommandSessionRecord | undefined | Promise; + set(record: CommandSessionRecord): void | Promise; + delete?(name: string): void | Promise; + list?(): readonly CommandSessionRecord[] | Promise; +}; + +export type CommandContext = { + session?: string; + requestId?: string; + signal?: AbortSignal; + metadata?: Record; +}; + +export type DiagnosticsSink = { + emit(event: { + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + data?: unknown; + }): void; +}; + +export type CommandClock = { + now(): number; + sleep(ms: number): Promise; +}; + +export type AgentDeviceRuntime = { + backend: AgentDeviceBackend; + artifacts: ArtifactAdapter; + sessions: CommandSessionStore; + policy: CommandPolicy; + diagnostics?: DiagnosticsSink; + clock?: CommandClock; + signal?: AbortSignal; +}; + +export type AgentDeviceRuntimeConfig = { + backend: AgentDeviceBackend; + artifacts: ArtifactAdapter; + sessions?: CommandSessionStore; + policy?: CommandPolicy; + diagnostics?: DiagnosticsSink; + clock?: CommandClock; + signal?: AbortSignal; +}; diff --git a/src/runtime.ts b/src/runtime.ts index 262384251..d4800f42a 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,80 +1,28 @@ import { hasBackendEscapeHatch, hasBackendCapability, - type AgentDeviceBackend, type BackendCapabilityName, } from './backend.ts'; -import type { ArtifactAdapter } from './io.ts'; -import type { SnapshotState } from './utils/snapshot.ts'; import { AppError } from './utils/errors.ts'; import { bindCommands, type BoundAgentDeviceCommands } from './commands/index.ts'; - -export type CommandPolicy = { - allowLocalInputPaths: boolean; - allowLocalOutputPaths: boolean; - maxImagePixels: number; - allowNamedBackendCapabilities: readonly BackendCapabilityName[]; -}; - -export type CommandSessionRecord = { - name: string; - appId?: string; - appBundleId?: string; - appName?: string; - backendSessionId?: string; - snapshot?: SnapshotState; - metadata?: Record; -}; - -// Runtime commands can read and then write the same session. CommandSessionStore -// implementations that are shared across concurrent callers should serialize -// per-session updates, or route commands through a transport that already does. -export type CommandSessionStore = { - get(name: string): CommandSessionRecord | undefined | Promise; - set(record: CommandSessionRecord): void | Promise; - delete?(name: string): void | Promise; - list?(): readonly CommandSessionRecord[] | Promise; -}; - -export type CommandContext = { - session?: string; - requestId?: string; - signal?: AbortSignal; - metadata?: Record; -}; - -export type DiagnosticsSink = { - emit(event: { - level: 'debug' | 'info' | 'warn' | 'error'; - message: string; - data?: unknown; - }): void; -}; - -export type CommandClock = { - now(): number; - sleep(ms: number): Promise; -}; - -export type AgentDeviceRuntime = { - backend: AgentDeviceBackend; - artifacts: ArtifactAdapter; - sessions: CommandSessionStore; - policy: CommandPolicy; - diagnostics?: DiagnosticsSink; - clock?: CommandClock; - signal?: AbortSignal; -}; - -export type AgentDeviceRuntimeConfig = { - backend: AgentDeviceBackend; - artifacts: ArtifactAdapter; - sessions?: CommandSessionStore; - policy?: CommandPolicy; - diagnostics?: DiagnosticsSink; - clock?: CommandClock; - signal?: AbortSignal; -}; +import type { + AgentDeviceRuntime, + AgentDeviceRuntimeConfig, + CommandPolicy, + CommandSessionRecord, + CommandSessionStore, +} from './runtime-contract.ts'; + +export type { + AgentDeviceRuntime, + AgentDeviceRuntimeConfig, + CommandClock, + CommandContext, + CommandPolicy, + CommandSessionRecord, + CommandSessionStore, + DiagnosticsSink, +} from './runtime-contract.ts'; export type AgentDevice = AgentDeviceRuntime & BoundAgentDeviceCommands; diff --git a/src/utils/__tests__/errors.test.ts b/src/utils/__tests__/errors.test.ts index 7aeeef6cc..c87f12d7f 100644 --- a/src/utils/__tests__/errors.test.ts +++ b/src/utils/__tests__/errors.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { AppError, asAppError, normalizeError } from '../errors.ts'; +import { AppError, asAppError, normalizeError, toAppErrorCode } from '../errors.ts'; test('normalizeError adds default hint and strips diagnostic metadata from details', () => { const err = new AppError('COMMAND_FAILED', 'runner failed', { @@ -91,3 +91,15 @@ test('normalizeError provides app discovery guidance for app-not-installed error /Run apps to discover the exact installed package or bundle id/i, ); }); + +test('toAppErrorCode preserves handler-emitted codes verbatim (including AMBIGUOUS_MATCH)', () => { + assert.equal(toAppErrorCode('AMBIGUOUS_MATCH'), 'AMBIGUOUS_MATCH'); + assert.equal(toAppErrorCode('SOME_FUTURE_CODE'), 'SOME_FUTURE_CODE'); + assert.equal(toAppErrorCode('DEVICE_IN_USE'), 'DEVICE_IN_USE'); +}); + +test('toAppErrorCode falls back when code is missing or empty', () => { + assert.equal(toAppErrorCode(undefined), 'COMMAND_FAILED'); + assert.equal(toAppErrorCode(''), 'COMMAND_FAILED'); + assert.equal(toAppErrorCode(undefined, 'UNAUTHORIZED'), 'UNAUTHORIZED'); +}); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index ec4ca4716..c63cd557e 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,8 +1,9 @@ import { redactDiagnosticData } from './redaction.ts'; -export type AppErrorCode = +export type KnownAppErrorCode = | 'INVALID_ARGS' | 'DEVICE_NOT_FOUND' + | 'DEVICE_IN_USE' | 'TOOL_MISSING' | 'APP_NOT_INSTALLED' | 'UNSUPPORTED_PLATFORM' @@ -11,8 +12,24 @@ export type AppErrorCode = | 'COMMAND_FAILED' | 'SESSION_NOT_FOUND' | 'UNAUTHORIZED' + | 'AMBIGUOUS_MATCH' | 'UNKNOWN'; +// Intentionally widened with `(string & {})` so daemon-originated codes pass +// through verbatim without requiring the SDK union to be updated first. Known +// codes still autocomplete in IDEs. Tradeoff: `switch (err.code)` is no longer +// exhaustive by construction — SDK consumers handling unknown codes should +// include a default branch. +export type AppErrorCode = KnownAppErrorCode | (string & {}); + +export function toAppErrorCode( + code: string | undefined, + fallback: AppErrorCode = 'COMMAND_FAILED', +): AppErrorCode { + if (typeof code === 'string' && code.length > 0) return code; + return fallback; +} + type AppErrorDetails = Record & { hint?: string; diagnosticId?: string; diff --git a/src/utils/process-identity.ts b/src/utils/process-identity.ts index 5c0f45d19..20296e9fb 100644 --- a/src/utils/process-identity.ts +++ b/src/utils/process-identity.ts @@ -1,4 +1,5 @@ import { runCmdSync } from './exec.ts'; +import { sleep } from './timeouts.ts'; const PS_TIMEOUT_MS = 1_000; const DAEMON_COMMAND_PATTERNS = [ @@ -78,7 +79,7 @@ export async function waitForProcessExit(pid: number, timeoutMs: number): Promis if (!isProcessAlive(pid)) return true; const start = Date.now(); while (Date.now() - start < timeoutMs) { - await new Promise((resolve) => setTimeout(resolve, 50)); + await sleep(50); if (!isProcessAlive(pid)) return true; } return !isProcessAlive(pid); diff --git a/src/utils/rect-visibility.ts b/src/utils/rect-visibility.ts index 576b4ccfe..b743313ab 100644 --- a/src/utils/rect-visibility.ts +++ b/src/utils/rect-visibility.ts @@ -45,22 +45,6 @@ export function isRectVisibleInViewport(targetRect: Rect, viewportRect: Rect): b ); } -export function distanceFromSafeViewportBand(targetRect: Rect, viewportRect: Rect): number { - const viewportHeight = Math.max(1, viewportRect.height); - const viewportTop = viewportRect.y; - const viewportBottom = viewportRect.y + viewportHeight; - const safeTop = viewportTop + viewportHeight * 0.25; - const safeBottom = viewportBottom - viewportHeight * 0.25; - const targetCenterY = targetRect.y + targetRect.height / 2; - if (targetCenterY < safeTop) return Math.ceil(safeTop - targetCenterY); - if (targetCenterY > safeBottom) return Math.ceil(targetCenterY - safeBottom); - return 0; -} - -export function isRectWithinSafeViewportBand(targetRect: Rect, viewportRect: Rect): boolean { - return distanceFromSafeViewportBand(targetRect, viewportRect) === 0; -} - function hasValidRect(rect: Rect | undefined): rect is Rect { if (!rect) return false; return ( diff --git a/src/utils/screenshot-diff-region-split.ts b/src/utils/screenshot-diff-region-split.ts index a84c44a46..86e067a03 100644 --- a/src/utils/screenshot-diff-region-split.ts +++ b/src/utils/screenshot-diff-region-split.ts @@ -1,5 +1,5 @@ import { PNG } from 'pngjs'; -import type { MutableDiffRegion } from './screenshot-diff-regions.ts'; +import type { MutableDiffRegion } from './screenshot-diff-region-types.ts'; // Region splitting is based on screen-relative heights so it works on phone, // tablet, and desktop screenshots; the pixel floors only suppress tiny fixtures/noise. diff --git a/src/utils/screenshot-diff-region-types.ts b/src/utils/screenshot-diff-region-types.ts new file mode 100644 index 000000000..3e42e45a2 --- /dev/null +++ b/src/utils/screenshot-diff-region-types.ts @@ -0,0 +1,13 @@ +export type MutableDiffRegion = { + minX: number; + minY: number; + maxX: number; + maxY: number; + differentPixels: number; + baselineRed: number; + baselineGreen: number; + baselineBlue: number; + currentRed: number; + currentGreen: number; + currentBlue: number; +}; diff --git a/src/utils/screenshot-diff-regions.ts b/src/utils/screenshot-diff-regions.ts index fd736b478..861f0202b 100644 --- a/src/utils/screenshot-diff-regions.ts +++ b/src/utils/screenshot-diff-regions.ts @@ -1,5 +1,8 @@ import { PNG } from 'pngjs'; import { splitLargeDiffRegions } from './screenshot-diff-region-split.ts'; +import type { MutableDiffRegion } from './screenshot-diff-region-types.ts'; + +export type { MutableDiffRegion } from './screenshot-diff-region-types.ts'; type ScreenshotDiffColor = { r: number; @@ -44,20 +47,6 @@ const BAND_MIN_ASPECT_RATIO = 2.5; const LARGE_REGION_MIN_AREA_RATIO = 0.04; const MEDIUM_REGION_MIN_AREA_RATIO = 0.01; -export type MutableDiffRegion = { - minX: number; - minY: number; - maxX: number; - maxY: number; - differentPixels: number; - baselineRed: number; - baselineGreen: number; - baselineBlue: number; - currentRed: number; - currentGreen: number; - currentBlue: number; -}; - export function summarizeDiffRegions(params: { diffMask: Uint8Array; baseline: PNG; diff --git a/src/utils/snapshot-diff.ts b/src/utils/snapshot-diff.ts index 6e3ff9489..38795381a 100644 --- a/src/utils/snapshot-diff.ts +++ b/src/utils/snapshot-diff.ts @@ -6,18 +6,18 @@ import { formatSnapshotLine, } from './snapshot-lines.ts'; -type SnapshotDiffLine = { +export type SnapshotDiffLine = { kind: 'added' | 'removed' | 'unchanged'; text: string; }; -type SnapshotDiffSummary = { +export type SnapshotDiffSummary = { additions: number; removals: number; unchanged: number; }; -type SnapshotDiffResult = { +export type SnapshotDiffResult = { summary: SnapshotDiffSummary; lines: SnapshotDiffLine[]; }; diff --git a/src/utils/snapshot-processing.ts b/src/utils/snapshot-processing.ts index 2f16c10dd..a2c038c13 100644 --- a/src/utils/snapshot-processing.ts +++ b/src/utils/snapshot-processing.ts @@ -1,6 +1,8 @@ import type { Platform } from './device.ts'; import type { RawSnapshotNode, SnapshotState } from './snapshot.ts'; -import { extractReadableText } from './text-surface.ts'; +import { extractReadableText, normalizeType } from './text-surface.ts'; + +export { normalizeType }; export function findNodeByLabel(nodes: SnapshotState['nodes'], label: string) { const query = label.toLowerCase(); @@ -79,19 +81,6 @@ export function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] { return result; } -export function normalizeType(type: string): string { - let value = type.trim().replace(/XCUIElementType/gi, ''); - if (value.startsWith('AX')) { - value = value.slice(2); - } - value = value.toLowerCase(); - const lastSeparator = Math.max(value.lastIndexOf('.'), value.lastIndexOf('/')); - if (lastSeparator !== -1) { - value = value.slice(lastSeparator + 1); - } - return value; -} - export function isFillableType(type: string, platform: Platform): boolean { const normalized = normalizeType(type); if (!normalized) return true; diff --git a/src/utils/text-surface.ts b/src/utils/text-surface.ts index 038962869..f8f4070d5 100644 --- a/src/utils/text-surface.ts +++ b/src/utils/text-surface.ts @@ -22,7 +22,7 @@ export function isLargeTextSurface(node: TextSurfaceNode, displayType?: string): if (displayType === 'text-view' || displayType === 'text-field' || displayType === 'search') { return true; } - const normalized = normalizeTextSurfaceType(node.type ?? ''); + const normalized = normalizeType(node.type ?? ''); const rawRole = `${node.role ?? ''} ${node.subrole ?? ''}`.toLowerCase(); return ( normalized.includes('textview') || @@ -72,19 +72,7 @@ export function trimText(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function prefersValueForReadableText(type: string): boolean { - const normalized = normalizeTextSurfaceType(type); - return ( - normalized.includes('textfield') || - normalized.includes('securetextfield') || - normalized.includes('searchfield') || - normalized.includes('edittext') || - normalized.includes('textview') || - normalized.includes('textarea') - ); -} - -function normalizeTextSurfaceType(type: string): string { +export function normalizeType(type: string): string { let normalized = type .trim() .replace(/XCUIElementType/gi, '') @@ -97,6 +85,18 @@ function normalizeTextSurfaceType(type: string): string { return normalized; } +function prefersValueForReadableText(type: string): boolean { + const normalized = normalizeType(type); + return ( + normalized.includes('textfield') || + normalized.includes('securetextfield') || + normalized.includes('searchfield') || + normalized.includes('edittext') || + normalized.includes('textview') || + normalized.includes('textarea') + ); +} + function isMeaningfulReadableIdentifier(value: string): boolean { if (!value) { return false; diff --git a/src/utils/timeouts.ts b/src/utils/timeouts.ts index 269d09306..a5b878839 100644 --- a/src/utils/timeouts.ts +++ b/src/utils/timeouts.ts @@ -5,6 +5,10 @@ export function resolveTimeoutMs(raw: string | undefined, fallback: number, min: return Math.max(min, Math.floor(parsed)); } +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** Alias for `resolveTimeoutMs` — semantically marks the caller expects seconds. */ export function resolveTimeoutSeconds( raw: string | undefined, diff --git a/src/utils/video.ts b/src/utils/video.ts index 996f1bf83..048227530 100644 --- a/src/utils/video.ts +++ b/src/utils/video.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { AppError } from './errors.ts'; import { runCmd } from './exec.ts'; +import { sleep } from './timeouts.ts'; const VIDEO_VALIDATION_SCRIPT = ` import Foundation @@ -55,7 +56,7 @@ export async function waitForStableFile( } previousSize = currentSize; - await new Promise((resolve) => setTimeout(resolve, pollMs)); + await sleep(pollMs); } } @@ -92,7 +93,7 @@ export async function waitForPlayableVideo( if (await isPlayableVideo(filePath)) { return; } - await new Promise((resolve) => setTimeout(resolve, pollMs)); + await sleep(pollMs); } }