diff --git a/src/cli.ts b/src/cli.ts index f46ba272e..3f1bef906 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { readVersion } from './utils/version.ts'; import { readCommandMessage } from './utils/success-text.ts'; import { pathToFileURL } from 'node:url'; import { sendToDaemon } from './daemon-client.ts'; +import { throwDaemonError } from './daemon-error.ts'; import fs from 'node:fs'; import type { BatchStep } from './core/dispatch.ts'; import { parseBatchStepsJson } from './core/batch.ts'; @@ -209,19 +210,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): flags: batchFlags, }); if (!response.ok) { - throw new AppError(response.error.code as any, response.error.message, { - ...(response.error.details ?? {}), - hint: response.error.hint, - diagnosticId: response.error.diagnosticId, - logPath: response.error.logPath, - }); + throwDaemonError(response.error); } if (flags.json) { printJson({ success: true, data: response.data ?? {} }); } else { renderBatchSummary(response.data ?? {}); } - if (logTailStopper) logTailStopper(); return; } @@ -233,7 +228,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } if (await tryRunClientBackedCommand({ command, positionals, flags, client })) { - if (logTailStopper) logTailStopper(); return; } @@ -248,362 +242,15 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): }); if (response.ok) { - if (flags.json) { - if (command === 'test') { - const testExitCode = renderReplayTestResponse({ - suite: (response.data ?? {}) as ReplaySuiteResult, - json: true, - reportJunit: flags.reportJunit, - }); - if (logTailStopper) logTailStopper(); - if (testExitCode !== 0) { - process.exit(testExitCode); - } - return; - } - printJson({ success: true, data: response.data ?? {} }); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'snapshot') { - process.stdout.write( - formatSnapshotText((response.data ?? {}) as Record, { - raw: flags.snapshotRaw, - flatten: flags.snapshotInteractiveOnly, - }), - ); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'test') { - const testExitCode = renderReplayTestResponse({ - suite: (response.data ?? {}) as ReplaySuiteResult, - verbose: flags.verbose, - reportJunit: flags.reportJunit, - }); - if (logTailStopper) logTailStopper(); - if (testExitCode !== 0) { - process.exit(testExitCode); - } - return; - } - if (command === 'diff' && positionals[0] === 'snapshot') { - process.stdout.write( - formatSnapshotDiffText((response.data ?? {}) as Record), - ); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'get') { - const sub = positionals[0]; - if (sub === 'text') { - const text = (response.data as any)?.text ?? ''; - process.stdout.write(`${text}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (sub === 'attrs') { - const node = (response.data as any)?.node ?? {}; - process.stdout.write(`${JSON.stringify(node, null, 2)}\n`); - if (logTailStopper) logTailStopper(); - return; - } - } - if (command === 'find') { - const data = response.data as any; - if (typeof data?.text === 'string') { - process.stdout.write(`${data.text}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (typeof data?.found === 'boolean') { - process.stdout.write(`Found: ${data.found}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (data?.node) { - process.stdout.write(`${JSON.stringify(data.node, null, 2)}\n`); - if (logTailStopper) logTailStopper(); - return; - } - } - if (command === 'is') { - const predicate = (response.data as any)?.predicate ?? 'assertion'; - process.stdout.write(`Passed: is ${predicate}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'boot') { - const platform = (response.data as any)?.platform ?? 'unknown'; - const device = - (response.data as any)?.device ?? (response.data as any)?.id ?? 'unknown'; - process.stdout.write(`Boot ready: ${device} (${platform})\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'ensure-simulator') { - const data = response.data as Record | undefined; - const udid = typeof data?.udid === 'string' ? data.udid : 'unknown'; - const device = typeof data?.device === 'string' ? data.device : 'unknown'; - const runtime = typeof data?.runtime === 'string' ? data.runtime : ''; - const created = data?.created === true; - const booted = data?.booted === true; - const action = created ? 'Created' : 'Reused'; - const bootedSuffix = booted ? ' (booted)' : ''; - process.stdout.write(`${action}: ${device} ${udid}${bootedSuffix}\n`); - if (runtime) process.stdout.write(`Runtime: ${runtime}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'screenshot') { - const pathOut = - typeof (response.data as any)?.path === 'string' ? (response.data as any).path : ''; - if (pathOut) { - process.stdout.write(`${pathOut}\n`); - } - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'record') { - const data = response.data as Record | undefined; - const outPath = typeof data?.outPath === 'string' ? data.outPath : ''; - if (outPath) process.stdout.write(`${outPath}\n`); + const exitCode = writeCommandCliOutput(command, positionals, flags, response.data ?? {}); + if (exitCode !== 0) { if (logTailStopper) logTailStopper(); - return; + process.exit(exitCode); } - if (command === 'logs') { - const data = response.data as Record | undefined; - const pathOut = typeof data?.path === 'string' ? data.path : ''; - if (pathOut) { - process.stdout.write(`${pathOut}\n`); - const active = typeof data?.active === 'boolean' ? data.active : undefined; - const state = typeof data?.state === 'string' ? data.state : undefined; - const backend = typeof data?.backend === 'string' ? data.backend : undefined; - const sizeBytes = typeof data?.sizeBytes === 'number' ? data.sizeBytes : undefined; - const started = data?.started === true; - const stopped = data?.stopped === true; - const marked = data?.marked === true; - const cleared = data?.cleared === true; - const restarted = data?.restarted === true; - const removedRotatedFiles = - typeof data?.removedRotatedFiles === 'number' - ? data.removedRotatedFiles - : undefined; - if ( - !flags.json && - (active !== undefined || state || backend || sizeBytes !== undefined) - ) { - const meta = [ - active !== undefined ? `active=${active}` : '', - state ? `state=${state}` : '', - backend ? `backend=${backend}` : '', - sizeBytes !== undefined ? `sizeBytes=${sizeBytes}` : '', - ] - .filter(Boolean) - .join(' '); - if (meta) process.stderr.write(`${meta}\n`); - } - if ( - !flags.json && - (started || - stopped || - marked || - cleared || - restarted || - removedRotatedFiles !== undefined) - ) { - const actionMeta = [ - started ? 'started=true' : '', - stopped ? 'stopped=true' : '', - marked ? 'marked=true' : '', - cleared ? 'cleared=true' : '', - restarted ? 'restarted=true' : '', - removedRotatedFiles !== undefined - ? `removedRotatedFiles=${removedRotatedFiles}` - : '', - ] - .filter(Boolean) - .join(' '); - if (actionMeta) process.stderr.write(`${actionMeta}\n`); - } - if (data?.hint && !flags.json) { - process.stderr.write(`${data.hint}\n`); - } - if (Array.isArray(data?.notes) && !flags.json) { - for (const note of data.notes) { - if (typeof note === 'string' && note.length > 0) { - process.stderr.write(`${note}\n`); - } - } - } - } - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'clipboard') { - const data = response.data as Record | undefined; - const action = ( - positionals[0] ?? (typeof data?.action === 'string' ? data.action : '') - ).toLowerCase(); - if (action === 'read') { - const text = typeof data?.text === 'string' ? data.text : ''; - process.stdout.write(`${text}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (action === 'write') { - process.stdout.write('Clipboard updated\n'); - if (logTailStopper) logTailStopper(); - return; - } - } - if (command === 'network') { - const data = response.data as Record | undefined; - const pathOut = typeof data?.path === 'string' ? data.path : ''; - if (pathOut) { - process.stdout.write(`${pathOut}\n`); - } - const entries = Array.isArray(data?.entries) ? data.entries : []; - if (entries.length === 0) { - process.stdout.write('No recent HTTP(s) entries found.\n'); - } else { - for (const entry of entries as Array>) { - const method = typeof entry.method === 'string' ? entry.method : 'HTTP'; - const url = typeof entry.url === 'string' ? entry.url : ''; - const status = typeof entry.status === 'number' ? ` status=${entry.status}` : ''; - const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : ''; - const durationMs = - typeof entry.durationMs === 'number' ? ` durationMs=${entry.durationMs}` : ''; - process.stdout.write(`${timestamp}${method} ${url}${status}${durationMs}\n`); - if (typeof entry.headers === 'string') { - process.stdout.write(` headers: ${entry.headers}\n`); - } - if (typeof entry.requestBody === 'string') { - process.stdout.write(` request: ${entry.requestBody}\n`); - } - if (typeof entry.responseBody === 'string') { - process.stdout.write(` response: ${entry.responseBody}\n`); - } - } - } - const active = typeof data?.active === 'boolean' ? data.active : undefined; - const state = typeof data?.state === 'string' ? data.state : undefined; - const backend = typeof data?.backend === 'string' ? data.backend : undefined; - const scannedLines = - typeof data?.scannedLines === 'number' ? data.scannedLines : undefined; - const matchedLines = - typeof data?.matchedLines === 'number' ? data.matchedLines : undefined; - const include = typeof data?.include === 'string' ? data.include : undefined; - const meta = [ - active !== undefined ? `active=${active}` : '', - state ? `state=${state}` : '', - backend ? `backend=${backend}` : '', - include ? `include=${include}` : '', - scannedLines !== undefined ? `scannedLines=${scannedLines}` : '', - matchedLines !== undefined ? `matchedLines=${matchedLines}` : '', - ] - .filter(Boolean) - .join(' '); - if (meta) process.stderr.write(`${meta}\n`); - if (Array.isArray(data?.notes)) { - for (const note of data.notes) { - if (typeof note === 'string' && note.length > 0) { - process.stderr.write(`${note}\n`); - } - } - } - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'click' || command === 'press') { - const ref = (response.data as any)?.ref ?? ''; - const x = (response.data as any)?.x; - const y = (response.data as any)?.y; - if (ref && typeof x === 'number' && typeof y === 'number') { - process.stdout.write(`Tapped @${ref} (${x}, ${y})\n`); - if (logTailStopper) logTailStopper(); - return; - } - } - if (response.data && typeof response.data === 'object') { - const data = response.data as Record; - if (command === 'devices') { - const devices = Array.isArray((data as any).devices) ? (data as any).devices : []; - const lines = devices.map((d: any) => { - const name = d?.name ?? d?.id ?? 'unknown'; - const platform = d?.platform ?? 'unknown'; - const kind = d?.kind ? ` ${d.kind}` : ''; - const target = d?.target ? ` target=${d.target}` : ''; - const booted = typeof d?.booted === 'boolean' ? ` booted=${d.booted}` : ''; - return `${name} (${platform}${kind}${target})${booted}`; - }); - process.stdout.write(`${lines.join('\n')}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'apps') { - const apps = Array.isArray((data as any).apps) ? (data as any).apps : []; - const lines = apps.map((app: any) => { - if (typeof app === 'string') return app; - if (app && typeof app === 'object') { - const bundleId = app.bundleId ?? app.package; - const name = app.name ?? app.label; - if (name && bundleId) return `${name} (${bundleId})`; - if (bundleId) return String(bundleId); - return JSON.stringify(app); - } - return String(app); - }); - process.stdout.write(`${lines.join('\n')}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (command === 'appstate') { - const platform = (data as any)?.platform; - const appBundleId = (data as any)?.appBundleId; - const appName = (data as any)?.appName; - const source = (data as any)?.source; - const pkg = (data as any)?.package; - const activity = (data as any)?.activity; - if (platform === 'ios') { - process.stdout.write(`Foreground app: ${appName ?? appBundleId ?? 'unknown'}\n`); - if (appBundleId) process.stdout.write(`Bundle: ${appBundleId}\n`); - if (source) process.stdout.write(`Source: ${source}\n`); - if (logTailStopper) logTailStopper(); - return; - } - if (platform === 'android') { - process.stdout.write(`Foreground app: ${pkg ?? 'unknown'}\n`); - if (activity) process.stdout.write(`Activity: ${activity}\n`); - if (logTailStopper) logTailStopper(); - return; - } - } - if (command === 'perf') { - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); - if (logTailStopper) logTailStopper(); - return; - } - const successText = readCommandMessage(data); - if (successText) { - process.stdout.write(`${successText}\n`); - for (const extraLine of readCommandSuccessLines(command, data)) { - process.stdout.write(`${extraLine}\n`); - } - if (logTailStopper) logTailStopper(); - return; - } - } - if (logTailStopper) logTailStopper(); return; } - throw new AppError(response.error.code as any, response.error.message, { - ...(response.error.details ?? {}), - hint: response.error.hint, - diagnosticId: response.error.diagnosticId, - logPath: response.error.logPath, - }); + throwDaemonError(response.error); } catch (err) { const appErr = asAppError(err); const normalized = normalizeError(appErr, { @@ -614,7 +261,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): if (flags.json) { printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } }); } - if (logTailStopper) logTailStopper(); return; } if (flags.json) { @@ -642,6 +288,8 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } if (logTailStopper) logTailStopper(); process.exit(1); + } finally { + if (logTailStopper) logTailStopper(); } }, ); @@ -683,6 +331,277 @@ function readBatchStepFailure(error: Record | undefined): strin return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; } +function writeCommandCliOutput( + command: string, + positionals: string[], + flags: { + json?: boolean; + verbose?: boolean; + snapshotRaw?: boolean; + snapshotInteractiveOnly?: boolean; + reportJunit?: string; + }, + data: Record, +): number { + if (flags.json) { + if (command === 'test') { + return renderReplayTestResponse({ + suite: data as ReplaySuiteResult, + json: true, + reportJunit: flags.reportJunit, + }); + } + printJson({ success: true, data }); + return 0; + } + + if (command === 'snapshot') { + process.stdout.write( + formatSnapshotText(data, { + raw: flags.snapshotRaw, + flatten: flags.snapshotInteractiveOnly, + }), + ); + return 0; + } + if (command === 'test') { + return renderReplayTestResponse({ + suite: data as ReplaySuiteResult, + verbose: flags.verbose, + reportJunit: flags.reportJunit, + }); + } + if (command === 'diff' && positionals[0] === 'snapshot') { + process.stdout.write(formatSnapshotDiffText(data)); + return 0; + } + if (command === 'get') { + const sub = positionals[0]; + if (sub === 'text') { + process.stdout.write(`${(data as any)?.text ?? ''}\n`); + return 0; + } + if (sub === 'attrs') { + process.stdout.write(`${JSON.stringify((data as any)?.node ?? {}, null, 2)}\n`); + return 0; + } + } + if (command === 'find') { + if (typeof (data as any)?.text === 'string') { + process.stdout.write(`${(data as any).text}\n`); + return 0; + } + if (typeof (data as any)?.found === 'boolean') { + process.stdout.write(`Found: ${(data as any).found}\n`); + return 0; + } + if ((data as any)?.node) { + process.stdout.write(`${JSON.stringify((data as any).node, null, 2)}\n`); + return 0; + } + } + if (command === 'is') { + process.stdout.write(`Passed: is ${(data as any)?.predicate ?? 'assertion'}\n`); + return 0; + } + if (command === 'boot') { + const platform = (data as any)?.platform ?? 'unknown'; + const device = (data as any)?.device ?? (data as any)?.id ?? 'unknown'; + process.stdout.write(`Boot ready: ${device} (${platform})\n`); + return 0; + } + if (command === 'ensure-simulator') { + const udid = typeof data?.udid === 'string' ? data.udid : 'unknown'; + const device = typeof data?.device === 'string' ? data.device : 'unknown'; + const runtime = typeof data?.runtime === 'string' ? data.runtime : ''; + const action = data?.created === true ? 'Created' : 'Reused'; + const bootedSuffix = data?.booted === true ? ' (booted)' : ''; + process.stdout.write(`${action}: ${device} ${udid}${bootedSuffix}\n`); + if (runtime) process.stdout.write(`Runtime: ${runtime}\n`); + return 0; + } + if (command === 'screenshot') { + const pathOut = typeof (data as any)?.path === 'string' ? (data as any).path : ''; + if (pathOut) process.stdout.write(`${pathOut}\n`); + return 0; + } + if (command === 'record') { + const outPath = typeof data?.outPath === 'string' ? data.outPath : ''; + if (outPath) process.stdout.write(`${outPath}\n`); + return 0; + } + if (command === 'logs') { + writeLogsCliOutput(data, flags); + return 0; + } + if (command === 'clipboard') { + const action = ( + positionals[0] ?? (typeof data?.action === 'string' ? data.action : '') + ).toLowerCase(); + if (action === 'read') { + process.stdout.write(`${typeof data?.text === 'string' ? data.text : ''}\n`); + return 0; + } + if (action === 'write') { + process.stdout.write('Clipboard updated\n'); + return 0; + } + } + if (command === 'network') { + writeNetworkCliOutput(data); + return 0; + } + if (command === 'click' || command === 'press') { + const ref = (data as any)?.ref ?? ''; + const x = (data as any)?.x; + const y = (data as any)?.y; + if (ref && typeof x === 'number' && typeof y === 'number') { + process.stdout.write(`Tapped @${ref} (${x}, ${y})\n`); + return 0; + } + } + if (command === 'devices') { + const devices = Array.isArray((data as any).devices) ? (data as any).devices : []; + process.stdout.write( + `${devices + .map((d: any) => { + const name = d?.name ?? d?.id ?? 'unknown'; + const platform = d?.platform ?? 'unknown'; + const kind = d?.kind ? ` ${d.kind}` : ''; + const target = d?.target ? ` target=${d.target}` : ''; + const booted = typeof d?.booted === 'boolean' ? ` booted=${d.booted}` : ''; + return `${name} (${platform}${kind}${target})${booted}`; + }) + .join('\n')}\n`, + ); + return 0; + } + if (command === 'apps') { + const apps = Array.isArray((data as any).apps) ? (data as any).apps : []; + process.stdout.write( + `${apps + .map((app: any) => { + if (typeof app === 'string') return app; + if (app && typeof app === 'object') { + const bundleId = app.bundleId ?? app.package; + const name = app.name ?? app.label; + if (name && bundleId) return `${name} (${bundleId})`; + if (bundleId) return String(bundleId); + return JSON.stringify(app); + } + return String(app); + }) + .join('\n')}\n`, + ); + return 0; + } + if (command === 'appstate') { + const platform = (data as any)?.platform; + if (platform === 'ios') { + process.stdout.write( + `Foreground app: ${(data as any)?.appName ?? (data as any)?.appBundleId ?? 'unknown'}\n`, + ); + if ((data as any)?.appBundleId) + process.stdout.write(`Bundle: ${(data as any).appBundleId}\n`); + if ((data as any)?.source) process.stdout.write(`Source: ${(data as any).source}\n`); + return 0; + } + if (platform === 'android') { + process.stdout.write(`Foreground app: ${(data as any)?.package ?? 'unknown'}\n`); + if ((data as any)?.activity) process.stdout.write(`Activity: ${(data as any).activity}\n`); + return 0; + } + } + if (command === 'perf') { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return 0; + } + const successText = readCommandMessage(data); + if (successText) { + process.stdout.write(`${successText}\n`); + for (const extraLine of readCommandSuccessLines(command, data)) { + process.stdout.write(`${extraLine}\n`); + } + } + return 0; +} + +function writeLogsCliOutput(data: Record, flags: { json?: boolean }): void { + const pathOut = typeof data?.path === 'string' ? data.path : ''; + if (!pathOut) return; + process.stdout.write(`${pathOut}\n`); + const metaFields = ['active', 'state', 'backend', 'sizeBytes'] as const; + const meta = metaFields + .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) + .filter(Boolean) + .join(' '); + if (meta && !flags.json) process.stderr.write(`${meta}\n`); + const actionFields = [ + 'started', + 'stopped', + 'marked', + 'cleared', + 'restarted', + 'removedRotatedFiles', + ] as const; + const actionMeta = actionFields + .map((key) => { + const v = data[key]; + return v === true ? `${key}=true` : typeof v === 'number' ? `${key}=${v}` : ''; + }) + .filter(Boolean) + .join(' '); + if (actionMeta && !flags.json) process.stderr.write(`${actionMeta}\n`); + if (data?.hint && !flags.json) process.stderr.write(`${data.hint}\n`); + if (Array.isArray(data?.notes) && !flags.json) { + for (const note of data.notes) { + if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); + } + } +} + +function writeNetworkCliOutput(data: Record): void { + const pathOut = typeof data?.path === 'string' ? data.path : ''; + if (pathOut) process.stdout.write(`${pathOut}\n`); + const entries = Array.isArray(data?.entries) ? data.entries : []; + if (entries.length === 0) { + process.stdout.write('No recent HTTP(s) entries found.\n'); + } else { + for (const entry of entries as Array>) { + const method = typeof entry.method === 'string' ? entry.method : 'HTTP'; + const url = typeof entry.url === 'string' ? entry.url : ''; + const status = typeof entry.status === 'number' ? ` status=${entry.status}` : ''; + const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : ''; + const durationMs = + typeof entry.durationMs === 'number' ? ` durationMs=${entry.durationMs}` : ''; + process.stdout.write(`${timestamp}${method} ${url}${status}${durationMs}\n`); + if (typeof entry.headers === 'string') process.stdout.write(` headers: ${entry.headers}\n`); + if (typeof entry.requestBody === 'string') + process.stdout.write(` request: ${entry.requestBody}\n`); + if (typeof entry.responseBody === 'string') + process.stdout.write(` response: ${entry.responseBody}\n`); + } + } + const networkMetaFields = [ + 'active', + 'state', + 'backend', + 'include', + 'scannedLines', + 'matchedLines', + ] as const; + const meta = networkMetaFields + .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) + .filter(Boolean) + .join(' '); + if (meta) process.stderr.write(`${meta}\n`); + if (Array.isArray(data?.notes)) { + for (const note of data.notes) { + if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); + } + } +} + function readCommandSuccessLines(command: string, data: Record): string[] { if (command !== 'scrollintoview') { return []; diff --git a/src/client.ts b/src/client.ts index d3beb2f0a..f1da211e5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,6 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime } from './client-metro.ts'; -import { AppError } from './utils/errors.ts'; +import { throwDaemonError } from './daemon-error.ts'; import { buildFlags, buildMeta, @@ -57,12 +57,7 @@ export function createAgentDeviceClient( meta: buildMeta(merged), }); if (!response.ok) { - throw new AppError(response.error.code as any, response.error.message, { - ...(response.error.details ?? {}), - hint: response.error.hint, - diagnosticId: response.error.diagnosticId, - logPath: response.error.logPath, - }); + throwDaemonError(response.error); } return (response.data ?? {}) as Record; }; diff --git a/src/contracts.ts b/src/contracts.ts index 1d61ce55b..b60fb5ac2 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,5 +1,3 @@ -// Keep this public daemon contract shape aligned with MetroRuntimeHints in src/metro.ts -// and the internal MetroRuntimeHints in src/client-metro.ts. export type SessionRuntimeHints = { platform?: 'ios' | 'android'; metroHost?: string; diff --git a/src/daemon-error.ts b/src/daemon-error.ts new file mode 100644 index 000000000..da6192a5d --- /dev/null +++ b/src/daemon-error.ts @@ -0,0 +1,11 @@ +import { AppError } 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, { + ...(error.details ?? {}), + hint: error.hint, + diagnosticId: error.diagnosticId, + logPath: error.logPath, + }); +} diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index e8c4cb073..dd9298d47 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -194,7 +194,7 @@ async function handleFindWait( } await new Promise((resolve) => setTimeout(resolve, 300)); } - return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } }; + return errorResponse('COMMAND_FAILED', 'find wait timed out'); } async function handleFindExists(ctx: FindContext): Promise { @@ -280,7 +280,7 @@ async function handleFindFill( ): Promise { const { req, sessionName, sessionStore, session, invoke, command } = ctx; if (!value) { - return { ok: false, error: { code: 'INVALID_ARGS', message: 'find fill requires text' } }; + return errorResponse('INVALID_ARGS', 'find fill requires text'); } const response = await invoke({ token: req.token, @@ -305,10 +305,7 @@ async function handleFindFocus(ctx: FindContext, match: ResolvedMatch): Promise< const { req, sessionStore, session, device, command, logPath } = ctx; const coords = match.node.rect ? centerOfRect(match.node.rect) : null; if (!coords) { - return { - ok: false, - error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' }, - }; + return errorResponse('COMMAND_FAILED', 'matched element has no bounds'); } const response = await dispatchCommand( device, @@ -337,14 +334,11 @@ async function handleFindType( ): Promise { const { req, sessionStore, session, device, command, logPath } = ctx; if (!value) { - return { ok: false, error: { code: 'INVALID_ARGS', message: 'find type requires text' } }; + return errorResponse('INVALID_ARGS', 'find type requires text'); } const coords = match.node.rect ? centerOfRect(match.node.rect) : null; if (!coords) { - return { - ok: false, - error: { code: 'COMMAND_FAILED', message: 'matched element has no bounds' }, - }; + return errorResponse('COMMAND_FAILED', 'matched element has no bounds'); } await dispatchCommand(device, 'focus', [String(coords.x), String(coords.y)], req.flags?.out, { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), @@ -375,19 +369,16 @@ function buildAmbiguousMatchError( extractNodeText(candidate) || candidate.label || candidate.identifier || candidate.type || ''; return `@${candidate.ref}${label ? `(${label})` : ''}`; }); - return { - ok: false, - error: { - code: 'AMBIGUOUS_MATCH', - message: `find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`, - details: { - locator, - query, - matches: matches.length, - candidates, - }, + return errorResponse( + 'AMBIGUOUS_MATCH', + `find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`, + { + locator, + query, + matches: matches.length, + candidates, }, - }; + ); } type FindAction = diff --git a/src/daemon/handlers/handler-utils.ts b/src/daemon/handlers/handler-utils.ts index 942ee7bfa..02d73be4d 100644 --- a/src/daemon/handlers/handler-utils.ts +++ b/src/daemon/handlers/handler-utils.ts @@ -20,3 +20,34 @@ export function recordSessionAction( result: result ?? {}, }); } + +/** + * Flag keys inherited from a parent request (batch/replay) into child step flags. + * Shared between batch and replay so the inheritance rules stay in sync. + */ +export const INHERITED_PARENT_FLAG_KEYS: ReadonlyArray = [ + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'verbose', + 'out', +]; + +/** + * Merge parent flag values into child flags for keys that are undefined in the child. + */ +export function mergeParentFlags( + parentFlags: CommandFlags | undefined, + childFlags: CommandFlags, +): CommandFlags { + const parentRecord = (parentFlags ?? {}) as Record; + const childRecord = childFlags as Record; + for (const key of INHERITED_PARENT_FLAG_KEYS) { + if (childRecord[key] === undefined && parentRecord[key] !== undefined) { + childRecord[key] = parentRecord[key]; + } + } + return childFlags; +} diff --git a/src/daemon/handlers/install-source.ts b/src/daemon/handlers/install-source.ts index b3e094bf5..736ca7470 100644 --- a/src/daemon/handlers/install-source.ts +++ b/src/daemon/handlers/install-source.ts @@ -13,6 +13,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { resolveInstallFromSourceResultTarget } from '../../client-shared.ts'; import { AppError, normalizeError } from '../../utils/errors.ts'; import { withSuccessText } from '../../utils/success-text.ts'; +import { errorResponse } from './response.ts'; function normalizePlatform(platform: CommandFlags['platform']): 'ios' | 'android' | undefined { return platform === 'ios' || platform === 'android' ? platform : undefined; @@ -73,13 +74,10 @@ export async function handleInstallFromSourceCommand(params: { flags: req.flags, }); if (!isCommandSupportedOnDevice('install', device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'install_from_source is not supported on this device', - }, - }; + return errorResponse( + 'UNSUPPORTED_OPERATION', + 'install_from_source is not supported on this device', + ); } const requestSignal = getRequestSignal(req.meta?.requestId); diff --git a/src/daemon/handlers/interaction-fill.ts b/src/daemon/handlers/interaction-fill.ts index 43eabad80..170af4a86 100644 --- a/src/daemon/handlers/interaction-fill.ts +++ b/src/daemon/handlers/interaction-fill.ts @@ -21,6 +21,7 @@ import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-f import { resolveRefTargetWithRectRefresh, type ResolveRefTarget } from './interaction-targeting.ts'; import { unsupportedMacOsDesktopSurfaceInteraction } from './interaction-touch-policy.ts'; import type { RefSnapshotFlagGuardResponse } from './interaction-flags.ts'; +import { errorResponse } from './response.ts'; export async function handleFillCommand(params: { req: DaemonRequest; @@ -49,17 +50,11 @@ export async function handleFillCommand(params: { } } if (session && !isCommandSupportedOnDevice('fill', session.device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'fill is not supported on this device' }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'fill is not supported on this device'); } if (req.positionals?.[0]?.startsWith('@')) { if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; + return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); } const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags); if (invalidRefFlagsResponse) return invalidRefFlagsResponse; @@ -70,10 +65,7 @@ export async function handleFillCommand(params: { ? req.positionals.slice(2).join(' ') : req.positionals.slice(1).join(' '); if (!text) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' }, - }; + return errorResponse('INVALID_ARGS', 'fill requires text after ref'); } const resolvedRefFillTarget = await resolveRefTargetWithRectRefresh({ @@ -91,7 +83,7 @@ export async function handleFillCommand(params: { captureSnapshotForSession, resolveRefTarget, }); - if (!resolvedRefFillTarget.ok) return resolvedRefFillTarget.response; + if (!resolvedRefFillTarget.ok) return resolvedRefFillTarget; const { ref, node, snapshotNodes, point } = resolvedRefFillTarget.target; const nodeType = node.type ?? ''; @@ -140,10 +132,7 @@ export async function handleFillCommand(params: { } if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; + return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); } const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], { @@ -151,17 +140,11 @@ export async function handleFillCommand(params: { }); if (selectorArgs) { if (selectorArgs.rest.length === 0) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' }, - }; + return errorResponse('INVALID_ARGS', 'fill requires text after selector'); } const text = selectorArgs.rest.join(' ').trim(); if (!text) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' }, - }; + return errorResponse('INVALID_ARGS', 'fill requires text after selector'); } const chain = parseSelectorChain(selectorArgs.selectorExpression); @@ -184,13 +167,10 @@ export async function handleFillCommand(params: { { command: req.command }, ); if (!resolved || !resolved.node.rect) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }), - }, - }; + return errorResponse( + 'COMMAND_FAILED', + formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }), + ); } const node = resolved.node; @@ -235,11 +215,5 @@ export async function handleFillCommand(params: { }); } - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'fill requires x y text, @ref text, or selector text', - }, - }; + return errorResponse('INVALID_ARGS', 'fill requires x y text, @ref text, or selector text'); } diff --git a/src/daemon/handlers/interaction-flags.ts b/src/daemon/handlers/interaction-flags.ts index d66e71974..80612ec4e 100644 --- a/src/daemon/handlers/interaction-flags.ts +++ b/src/daemon/handlers/interaction-flags.ts @@ -1,5 +1,6 @@ import type { CommandFlags } from '../../core/dispatch.ts'; import type { DaemonResponse } from '../types.ts'; +import { errorResponse } from './response.ts'; const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [ ['snapshotDepth', '--depth'], @@ -13,13 +14,10 @@ export function refSnapshotFlagGuardResponse( ): DaemonResponse | null { const unsupported = unsupportedRefSnapshotFlags(flags); if (unsupported.length === 0) return null; - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `${command} @ref does not support ${unsupported.join(', ')}.`, - }, - }; + return errorResponse( + 'INVALID_ARGS', + `${command} @ref does not support ${unsupported.join(', ')}.`, + ); } export type RefSnapshotFlagGuardResponse = typeof refSnapshotFlagGuardResponse; diff --git a/src/daemon/handlers/interaction-get.ts b/src/daemon/handlers/interaction-get.ts index 8b6ee7e9f..5b5ff8dc9 100644 --- a/src/daemon/handlers/interaction-get.ts +++ b/src/daemon/handlers/interaction-get.ts @@ -6,28 +6,18 @@ 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 { - ok: false, - error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' }, - }; + return errorResponse('INVALID_ARGS', 'get only supports text or attrs'); } const session = sessionStore.get(sessionName); - if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; - } + if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); if (!isCommandSupportedOnDevice('get', session.device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'get is not supported on this device' }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'get is not supported on this device'); } const refInput = req.positionals?.[1] ?? ''; if (refInput.startsWith('@')) { @@ -43,7 +33,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis invalidRefMessage: 'get text requires a ref like @e2', notFoundMessage: `Ref ${refInput} not found`, }); - if (!resolvedRefTarget.ok) return resolvedRefTarget.response; + if (!resolvedRefTarget.ok) return resolvedRefTarget; const { ref, node } = resolvedRefTarget.target; const selectorChain = buildSelectorChainForNode(node, session.device.platform, { action: 'get', @@ -77,10 +67,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis const selectorExpression = req.positionals.slice(1).join(' ').trim(); if (!selectorExpression) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'get requires @ref or selector expression' }, - }; + return errorResponse('INVALID_ARGS', 'get requires @ref or selector expression'); } const resolvedSelectorTarget = await resolveSelectorTarget({ command: req.command, @@ -94,7 +81,7 @@ export async function handleGetCommand(params: InteractionHandlerParams): Promis requireUnique: true, disambiguateAmbiguous: sub === 'text', }); - if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response; + if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget; const { resolved } = resolvedSelectorTarget; const node = resolved.node; const selectorChain = buildSelectorChainForNode(node, session.device.platform, { diff --git a/src/daemon/handlers/interaction-is.ts b/src/daemon/handlers/interaction-is.ts index ad254c936..031c3d278 100644 --- a/src/daemon/handlers/interaction-is.ts +++ b/src/daemon/handlers/interaction-is.ts @@ -8,6 +8,7 @@ import { } 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'; @@ -15,55 +16,28 @@ export async function handleIsCommand(params: InteractionHandlerParams): Promise const { req, sessionName, sessionStore, contextFromFlags } = params; const predicate = (req.positionals?.[0] ?? '').toLowerCase(); if (!isSupportedPredicate(predicate)) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'is requires predicate: visible|hidden|exists|editable|selected|text', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'is requires predicate: visible|hidden|exists|editable|selected|text', + ); } const session = sessionStore.get(sessionName); if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; + return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); } if (!isCommandSupportedOnDevice('is', session.device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'is is not supported on this device' }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'is is not supported on this device'); } const { split } = splitIsSelectorArgs(req.positionals); if (!split) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'is requires a selector expression', - }, - }; + return errorResponse('INVALID_ARGS', 'is requires a selector expression'); } const expectedText = split.rest.join(' ').trim(); if (predicate === 'text' && !expectedText) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'is text requires expected text value', - }, - }; + return errorResponse('INVALID_ARGS', 'is text requires expected text value'); } if (predicate !== 'text' && split.rest.length > 0) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `is ${predicate} does not accept trailing values`, - }, - }; + return errorResponse('INVALID_ARGS', `is ${predicate} does not accept trailing values`); } const chain = parseSelectorChain(split.selectorExpression); if (predicate === 'exists') { @@ -78,13 +52,7 @@ export async function handleIsCommand(params: InteractionHandlerParams): Promise platform: session.device.platform, }); if (!matched) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, [], { unique: false }), - }, - }; + return errorResponse('COMMAND_FAILED', formatSelectorFailure(chain, [], { unique: false })); } sessionStore.recordAction(session, { command: req.command, @@ -116,7 +84,7 @@ export async function handleIsCommand(params: InteractionHandlerParams): Promise requireUnique: true, disambiguateAmbiguous: false, }); - if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response; + if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget; const { resolved } = resolvedSelectorTarget; const result = evaluateIsPredicate({ predicate, @@ -126,13 +94,10 @@ export async function handleIsCommand(params: InteractionHandlerParams): Promise platform: session.device.platform, }); if (!result.pass) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `is ${predicate} failed for selector ${resolved.selector.raw}: ${result.details}`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `is ${predicate} failed for selector ${resolved.selector.raw}: ${result.details}`, + ); } sessionStore.recordAction(session, { command: req.command, diff --git a/src/daemon/handlers/interaction-press.ts b/src/daemon/handlers/interaction-press.ts index 09e296d11..e40c64cea 100644 --- a/src/daemon/handlers/interaction-press.ts +++ b/src/daemon/handlers/interaction-press.ts @@ -151,7 +151,7 @@ export async function handlePressCommand(params: { captureSnapshotForSession, resolveRefTarget, }); - if (!resolvedRefPressTarget.ok) return resolvedRefPressTarget.response; + if (!resolvedRefPressTarget.ok) return resolvedRefPressTarget; const { ref, node, snapshotNodes, point: pressPoint } = resolvedRefPressTarget.target; const refLabel = resolveRefLabel(node, snapshotNodes); @@ -192,13 +192,10 @@ export async function handlePressCommand(params: { const selectorExpression = (req.positionals ?? []).join(' ').trim(); if (!selectorExpression) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `${commandLabel} requires @ref, selector expression, or x y coordinates`, - }, - }; + return errorResponse( + 'INVALID_ARGS', + `${commandLabel} requires @ref, selector expression, or x y coordinates`, + ); } const chain = parseSelectorChain(selectorExpression); @@ -221,25 +218,19 @@ export async function handlePressCommand(params: { { command }, ); if (!resolved || !resolved.node.rect) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }), - }, - }; + return errorResponse( + 'COMMAND_FAILED', + formatSelectorFailure(chain, resolved?.diagnostics ?? [], { unique: true }), + ); } const actionableNode = resolveActionableTouchNode(snapshot.nodes, resolved.node); const pressPoint = resolveRectCenter(actionableNode.rect); if (!pressPoint) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Selector ${resolved.selector.raw} resolved to invalid bounds`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `Selector ${resolved.selector.raw} resolved to invalid bounds`, + ); } const { x, y } = pressPoint; diff --git a/src/daemon/handlers/interaction-scroll.ts b/src/daemon/handlers/interaction-scroll.ts index 07eb789fb..6432087de 100644 --- a/src/daemon/handlers/interaction-scroll.ts +++ b/src/daemon/handlers/interaction-scroll.ts @@ -19,6 +19,7 @@ import type { InteractionHandlerParams } from './interaction-common.ts'; import { refSnapshotFlagGuardResponse } from './interaction-flags.ts'; import { captureSnapshotForSession } from './interaction-snapshot.ts'; import { resolveRefTarget } from './interaction-targeting.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; type ScrollRefState = { ref: string; @@ -41,19 +42,10 @@ export async function handleScrollIntoViewCommand( const { req, sessionName, sessionStore, contextFromFlags } = params; const session = sessionStore.get(sessionName); if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; + return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); } if (!isCommandSupportedOnDevice('scrollintoview', session.device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'scrollintoview is not supported on this device', - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'scrollintoview is not supported on this device'); } const targetInput = req.positionals?.[0] ?? ''; if (!targetInput.startsWith('@')) { @@ -64,7 +56,7 @@ export async function handleScrollIntoViewCommand( const fallbackLabel = req.positionals && req.positionals.length > 1 ? req.positionals.slice(1).join(' ').trim() : ''; const initialState = resolveInitialScrollRefState(session, targetInput, fallbackLabel); - if (!initialState.ok) return initialState.response; + if (!initialState.ok) return initialState; const { ref } = initialState.state; let { currentRef, node, snapshotNodes, viewportRect } = initialState.state; @@ -130,7 +122,7 @@ export async function handleScrollIntoViewCommand( selectorChain, platform: session.device.platform, }); - if (!refreshedState.ok) return refreshedState.response; + if (!refreshedState.ok) return refreshedState; ({ currentRef, node, snapshotNodes, viewportRect } = refreshedState.state); const distance = distanceFromSafeViewportBand(node.rect, viewportRect); @@ -186,7 +178,7 @@ function resolveInitialScrollRefState( session: SessionState, targetInput: string, fallbackLabel: string, -): { ok: true; state: ScrollRefState } | { ok: false; response: DaemonResponse } { +): { ok: true; state: ScrollRefState } | DaemonFailureResponse { const resolvedRefTarget = resolveRefTarget({ session, refInput: targetInput, @@ -196,16 +188,12 @@ function resolveInitialScrollRefState( notFoundMessage: `Ref ${targetInput} not found or has no bounds`, }); if (!resolvedRefTarget.ok) { - const { response } = resolvedRefTarget; - if (response.ok || response.error.code !== 'COMMAND_FAILED') { - return { ok: false, response }; + if (resolvedRefTarget.error.code !== 'COMMAND_FAILED') { + return resolvedRefTarget; } - return { - ok: false, - response: notFoundScrollResponse(targetInput, 0, { - message: response.error.message, - }), - }; + return notFoundScrollResponse(targetInput, 0, { + message: resolvedRefTarget.error.message, + }); } return finalizeScrollRefState(targetInput, 0, resolvedRefTarget.target); } @@ -218,7 +206,7 @@ function resolveRefreshedScrollRefState(params: { ref: string; selectorChain: string[]; platform: SessionState['device']['platform']; -}): { ok: true; state: ScrollRefState } | { ok: false; response: DaemonResponse } { +}): { ok: true; state: ScrollRefState } | DaemonFailureResponse { const { session, targetInput, fallbackLabel, attempts, ref, selectorChain, platform } = params; if (session.snapshot) { const trackedNode = resolveTrackedScrollNode( @@ -250,17 +238,13 @@ function resolveRefreshedScrollRefState(params: { notFoundMessage: `Ref ${targetInput} not found or has no bounds`, }); if (!resolvedRefTarget.ok) { - const { response } = resolvedRefTarget; - if (response.ok || response.error.code !== 'COMMAND_FAILED') { - return { ok: false, response }; + if (resolvedRefTarget.error.code !== 'COMMAND_FAILED') { + return resolvedRefTarget; } - return { - ok: false, - response: notFoundScrollResponse(targetInput, attempts, { - message: `scrollintoview lost track of ${targetInput} after ${attempts} scroll${attempts === 1 ? '' : 's'}`, - ref, - }), - }; + return notFoundScrollResponse(targetInput, attempts, { + message: `scrollintoview lost track of ${targetInput} after ${attempts} scroll${attempts === 1 ? '' : 's'}`, + ref, + }); } return finalizeScrollRefState(targetInput, attempts, resolvedRefTarget.target, { ref, @@ -274,30 +258,21 @@ function finalizeScrollRefState( attempts: number, resolvedTarget: { ref: string; node: SnapshotNode; snapshotNodes: SnapshotNode[] }, options: { ref?: string; currentRef?: string; missingBoundsMessage?: string } = {}, -): { ok: true; state: ScrollRefState } | { ok: false; response: DaemonResponse } { +): { ok: true; state: ScrollRefState } | DaemonFailureResponse { const { ref, currentRef, missingBoundsMessage } = options; const node = resolvedTarget.node; if (!node.rect) { - return { - ok: false, - response: notFoundScrollResponse(targetInput, attempts, { - message: missingBoundsMessage ?? `Ref ${targetInput} not found or has no bounds`, - ref: ref ?? resolvedTarget.ref, - }), - }; + return notFoundScrollResponse(targetInput, attempts, { + message: missingBoundsMessage ?? `Ref ${targetInput} not found or has no bounds`, + ref: ref ?? resolvedTarget.ref, + }); } const viewportRect = resolveViewportRect(resolvedTarget.snapshotNodes, node.rect); if (!viewportRect) { - return { - ok: false, - response: { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `scrollintoview could not infer viewport for ${targetInput}`, - }, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `scrollintoview could not infer viewport for ${targetInput}`, + ); } return { ok: true, @@ -335,21 +310,17 @@ function notFoundScrollResponse( targetInput: string, attempts: number, details: ScrollNotFoundDetails = {}, -): DaemonResponse { +): DaemonFailureResponse { const { message, ...rest } = details; - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: - typeof message === 'string' ? message : `scrollintoview could not find ${targetInput}`, - details: { - reason: 'not_found', - attempts, - ...rest, - }, + return errorResponse( + 'COMMAND_FAILED', + typeof message === 'string' ? message : `scrollintoview could not find ${targetInput}`, + { + reason: 'not_found', + attempts, + ...rest, }, - }; + ); } function buildScrollIntoViewSuccessData(params: { diff --git a/src/daemon/handlers/interaction-selector.ts b/src/daemon/handlers/interaction-selector.ts index e876b06af..956f8a3cb 100644 --- a/src/daemon/handlers/interaction-selector.ts +++ b/src/daemon/handlers/interaction-selector.ts @@ -1,10 +1,11 @@ import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { formatSelectorFailure, parseSelectorChain, resolveSelectorChain } from '../selectors.ts'; -import type { DaemonResponse, SessionState } from '../types.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; @@ -24,7 +25,7 @@ export async function resolveSelectorTarget(params: { snapshot: Awaited>; resolved: NonNullable>>; } - | { ok: false; response: DaemonResponse } + | DaemonFailureResponse > { const { command, @@ -56,14 +57,11 @@ export async function resolveSelectorTarget(params: { if (!resolved || (requireRect && !resolved.node.rect)) { return { ok: false, - response: { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, resolved?.diagnostics ?? [], { - unique: requireUnique, - }), - }, + error: { + code: 'COMMAND_FAILED', + message: formatSelectorFailure(chain, resolved?.diagnostics ?? [], { + unique: requireUnique, + }), }, }; } diff --git a/src/daemon/handlers/interaction-targeting.ts b/src/daemon/handlers/interaction-targeting.ts index 82df74cad..d2b529efe 100644 --- a/src/daemon/handlers/interaction-targeting.ts +++ b/src/daemon/handlers/interaction-targeting.ts @@ -8,7 +8,8 @@ import { } from '../../utils/snapshot.ts'; import { findNearestHittableAncestor, findNodeByLabel } from '../snapshot-processing.ts'; import type { SessionStore } from '../session-store.ts'; -import type { DaemonResponse, SessionState } from '../types.ts'; +import type { SessionState } from '../types.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; import type { CaptureSnapshotForSession } from './interaction-snapshot.ts'; import type { ContextFromFlags } from './interaction-common.ts'; import { @@ -36,34 +37,22 @@ export function resolveRefTarget(params: { notFoundMessage: string; }): | { ok: true; target: { ref: string; node: SnapshotNode; snapshotNodes: SnapshotNode[] } } - | { ok: false; response: DaemonResponse } { + | DaemonFailureResponse { const { session, refInput, fallbackLabel, requireRect, invalidRefMessage, notFoundMessage } = params; if (!session.snapshot) { - return { - ok: false, - response: { - ok: false, - error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' }, - }, - }; + return errorResponse('INVALID_ARGS', 'No snapshot in session. Run snapshot first.'); } const ref = normalizeRef(refInput); if (!ref) { - return { - ok: false, - response: { ok: false, error: { code: 'INVALID_ARGS', message: invalidRefMessage } }, - }; + return errorResponse('INVALID_ARGS', invalidRefMessage); } let node = findNodeByRef(session.snapshot.nodes, ref); if ((!node || (requireRect && !node.rect)) && fallbackLabel.length > 0) { node = findNodeByLabel(session.snapshot.nodes, fallbackLabel); } if (!node || (requireRect && !node.rect)) { - return { - ok: false, - response: { ok: false, error: { code: 'COMMAND_FAILED', message: notFoundMessage } }, - }; + return errorResponse('COMMAND_FAILED', notFoundMessage); } return { ok: true, target: { ref, node, snapshotNodes: session.snapshot.nodes } }; } @@ -118,7 +107,7 @@ export async function resolveRefTargetWithRectRefresh(params: { point: { x: number; y: number }; }; } - | { ok: false; response: DaemonResponse } + | DaemonFailureResponse > { const { session, @@ -143,7 +132,7 @@ export async function resolveRefTargetWithRectRefresh(params: { invalidRefMessage, notFoundMessage: missingBoundsMessage, }); - if (!resolvedRefTarget.ok) return { ok: false, response: resolvedRefTarget.response }; + if (!resolvedRefTarget.ok) return resolvedRefTarget; const { ref } = resolvedRefTarget.target; let node = promoteToHittableAncestor @@ -190,34 +179,22 @@ export async function resolveRefTargetWithRectRefresh(params: { } if (!point) { - return { - ok: false, - response: { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: invalidBoundsMessage, - }, - }, - }; + return errorResponse('COMMAND_FAILED', invalidBoundsMessage); } const viewport = node.rect ? resolveEffectiveViewportRect(node, snapshotNodes) : null; if (node.rect && viewport && !isNodeVisibleInEffectiveViewport(node, snapshotNodes)) { return { ok: false, - response: { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Ref ${refInput} is off-screen and not safe to ${commandLabel}`, - hint: `Run scrollintoview ${refInput}, then retry ${commandLabel} with the returned currentRef or a fresh snapshot.`, - details: { - reason: 'offscreen_ref', - ref, - rect: node.rect, - viewport, - }, + error: { + code: 'COMMAND_FAILED', + message: `Ref ${refInput} is off-screen and not safe to ${commandLabel}`, + hint: `Run scrollintoview ${refInput}, then retry ${commandLabel} with the returned currentRef or a fresh snapshot.`, + details: { + reason: 'offscreen_ref', + ref, + rect: node.rect, + viewport, }, }, }; diff --git a/src/daemon/handlers/interaction-touch-policy.ts b/src/daemon/handlers/interaction-touch-policy.ts index 5f3fbfe3e..34099164d 100644 --- a/src/daemon/handlers/interaction-touch-policy.ts +++ b/src/daemon/handlers/interaction-touch-policy.ts @@ -1,4 +1,5 @@ import type { DaemonResponse, SessionState } from '../types.ts'; +import { errorResponse } from './response.ts'; export function unsupportedMacOsDesktopSurfaceInteraction( session: SessionState, @@ -13,11 +14,8 @@ export function unsupportedMacOsDesktopSurfaceInteraction( if (session.surface === 'menubar' && (command === 'click' || command === 'press')) { return null; } - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: `${command} is not supported on macOS ${session.surface} sessions yet. Open an app session to act, or use the ${session.surface} surface to inspect.`, - }, - }; + return errorResponse( + 'UNSUPPORTED_OPERATION', + `${command} is not supported on macOS ${session.surface} sessions yet. Open an app session to act, or use the ${session.surface} surface to inspect.`, + ); } diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index 1a03f0454..1cdfac573 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -1,10 +1,10 @@ import fs from 'node:fs'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { getRecordingOverlaySupportWarning } from '../../recording/overlay.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; -import { persistRecordingTelemetry } from '../recording-telemetry.ts'; -import { formatRecordTraceError, formatRecordTraceExecFailure } from '../record-trace-errors.ts'; +import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; import type { RecordTraceDeps } from './record-trace-recording.ts'; +import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; +import { errorResponse } from './response.ts'; const ANDROID_REMOTE_FILE_POLL_MS = 250; const ANDROID_REMOTE_FILE_ATTEMPTS = 20; @@ -296,13 +296,7 @@ export async function startAndroidRecording(params: { await cleanupAndroidRemoteRecording(deps, device.id, remotePath); } - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: lastStartError, - }, - }; + return errorResponse('COMMAND_FAILED', lastStartError); } export async function stopAndroidRecording(params: { @@ -363,56 +357,24 @@ export async function stopAndroidRecording(params: { }); if (copyError) { await cleanupRemoteRecording(); - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: copyError, - }, - }; + return errorResponse('COMMAND_FAILED', copyError); } - persistRecordingTelemetry({ + await finalizeRecordingOverlay({ recording, + deps, + targetLabel: 'Android recording', }); - if (recording.showTouches && recording.telemetryPath) { - const overlaySupportWarning = getRecordingOverlaySupportWarning(); - if (overlaySupportWarning) { - recording.overlayWarning = overlaySupportWarning; - } else { - try { - await deps.overlayRecordingTouches({ - videoPath: recording.outPath, - telemetryPath: recording.telemetryPath, - targetLabel: 'Android recording', - }); - } catch (error) { - recording.overlayWarning = `failed to overlay recording touches: ${formatRecordTraceError(error)}`; - } - } - } } await cleanupRemoteRecording(); if (stopError) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: stopError, - }, - }; + return errorResponse('COMMAND_FAILED', stopError); } if (cleanupError) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: cleanupError, - }, - }; + return errorResponse('COMMAND_FAILED', cleanupError); } return null; diff --git a/src/daemon/handlers/record-trace-finalize.ts b/src/daemon/handlers/record-trace-finalize.ts new file mode 100644 index 000000000..08508dcdb --- /dev/null +++ b/src/daemon/handlers/record-trace-finalize.ts @@ -0,0 +1,45 @@ +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'; + +type FinalizeRecordingOverlayParams = { + recording: { + outPath: string; + gestureEvents: import('../types.ts').RecordingGestureEvent[]; + telemetryPath?: string; + showTouches: boolean; + overlayWarning?: string; + }; + deps: Pick; + trimStartMs?: number; + targetLabel: string; +}; + +export async function finalizeRecordingOverlay( + params: FinalizeRecordingOverlayParams, +): Promise { + const { recording, deps, trimStartMs, targetLabel } = params; + + const telemetryPath = persistRecordingTelemetry({ + recording, + trimStartMs, + }); + + if (recording.showTouches) { + const overlaySupportWarning = getRecordingOverlaySupportWarning(); + if (overlaySupportWarning) { + recording.overlayWarning = overlaySupportWarning; + } else { + try { + await deps.overlayRecordingTouches({ + videoPath: recording.outPath, + telemetryPath, + targetLabel, + }); + } catch (error) { + recording.overlayWarning = `failed to overlay recording touches: ${formatRecordTraceError(error)}`; + } + } + } +} diff --git a/src/daemon/handlers/record-trace-ios.ts b/src/daemon/handlers/record-trace-ios.ts index 9c150367c..cffc98cfc 100644 --- a/src/daemon/handlers/record-trace-ios.ts +++ b/src/daemon/handlers/record-trace-ios.ts @@ -1,11 +1,11 @@ import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { persistRecordingTelemetry } from '../recording-telemetry.ts'; import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/ios/runner-client.ts'; -import { getRecordingOverlaySupportWarning } from '../../recording/overlay.ts'; import { formatRecordTraceError } from '../record-trace-errors.ts'; import type { RecordTraceDeps, RecordingBase } from './record-trace-recording.ts'; +import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; +import { errorResponse } from './response.ts'; export function normalizeAppBundleId(session: SessionState): string | undefined { const trimmed = session.appBundleId?.trim(); @@ -141,13 +141,10 @@ export async function startIosDeviceRecording(params: { : undefined; } catch (error) { if (!isRunnerRecordingAlreadyInProgressError(error)) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `failed to start recording: ${formatRecordTraceError(error)}`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `failed to start recording: ${formatRecordTraceError(error)}`, + ); } emitDiagnostic({ @@ -168,13 +165,10 @@ export async function startIosDeviceRecording(params: { activeSession.name, ); if (otherRecordingSession) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `failed to start recording: recording already in progress in session '${otherRecordingSession.name}'`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `failed to start recording: recording already in progress in session '${otherRecordingSession.name}'`, + ); } try { @@ -194,13 +188,10 @@ export async function startIosDeviceRecording(params: { ? startResult.targetAppReadyUptimeMs : undefined; } catch (retryError) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `failed to start recording: ${formatRecordTraceError(retryError)}`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `failed to start recording: ${formatRecordTraceError(retryError)}`, + ); } } @@ -237,13 +228,10 @@ export async function startMacOsRecording(params: { getRunnerOptions(req, logPath, activeSession), ); } catch (error) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `failed to start recording: ${formatRecordTraceError(error)}`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `failed to start recording: ${formatRecordTraceError(error)}`, + ); } return { @@ -316,13 +304,7 @@ export async function stopIosDeviceRecording(params: { copyResult.stderr.trim() || copyResult.stdout.trim() || `devicectl exited with code ${copyResult.exitCode}`; - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `failed to copy recording from device: ${copyError}`, - }, - }; + return errorResponse('COMMAND_FAILED', `failed to copy recording from device: ${copyError}`); } const trimStartMs = resolveIosRecordingTrimStartMs(recording); @@ -333,28 +315,13 @@ export async function stopIosDeviceRecording(params: { }); } - const telemetryPath = persistRecordingTelemetry({ + await finalizeRecordingOverlay({ recording, + deps, trimStartMs, + targetLabel: 'iOS recording', }); - if (recording.showTouches) { - const overlaySupportWarning = getRecordingOverlaySupportWarning(); - if (overlaySupportWarning) { - recording.overlayWarning = overlaySupportWarning; - } else { - try { - await deps.overlayRecordingTouches({ - videoPath: recording.outPath, - telemetryPath, - targetLabel: 'iOS recording', - }); - } catch (error) { - recording.overlayWarning = `failed to overlay recording touches: ${formatRecordTraceError(error)}`; - } - } - } - return null; } @@ -389,26 +356,11 @@ export async function stopMacOsRecording(params: { }); } - const telemetryPath = persistRecordingTelemetry({ + await finalizeRecordingOverlay({ recording, + deps, + targetLabel: 'macOS recording', }); - if (recording.showTouches) { - const overlaySupportWarning = getRecordingOverlaySupportWarning(); - if (overlaySupportWarning) { - recording.overlayWarning = overlaySupportWarning; - } else { - try { - await deps.overlayRecordingTouches({ - videoPath: recording.outPath, - telemetryPath, - targetLabel: 'macOS recording', - }); - } catch (error) { - recording.overlayWarning = `failed to overlay recording touches: ${formatRecordTraceError(error)}`; - } - } - } - return null; } diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 3f1669920..ccd7a3359 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -13,15 +13,13 @@ import type { } from '../types.ts'; import { runCmd, runCmdBackground } from '../../utils/exec.ts'; import { isPlayableVideo, waitForStableFile } from '../../utils/video.ts'; -import { deriveRecordingTelemetryPath, persistRecordingTelemetry } from '../recording-telemetry.ts'; +import { deriveRecordingTelemetryPath } from '../recording-telemetry.ts'; import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; -import { - getRecordingOverlaySupportWarning, - overlayRecordingTouches, - trimRecordingStart, -} from '../../recording/overlay.ts'; +import { overlayRecordingTouches, trimRecordingStart } from '../../recording/overlay.ts'; import { buildSimctlArgsForDevice } from '../../platforms/ios/simctl.ts'; -import { formatRecordTraceError, formatRecordTraceExecFailure } from '../record-trace-errors.ts'; +import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; +import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; +import { errorResponse } from './response.ts'; import { startAndroidRecording, stopAndroidRecording } from './record-trace-android.ts'; import { normalizeAppBundleId, @@ -178,10 +176,7 @@ async function startRecording(params: { const { req, sessionName, sessionStore, activeSession, device, logPath, deps } = params; if (activeSession.recording) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'recording already in progress' }, - }; + return errorResponse('INVALID_ARGS', 'recording already in progress'); } const fpsFlag = req.flags?.fps; @@ -191,23 +186,14 @@ async function startRecording(params: { fpsFlag < IOS_DEVICE_RECORD_MIN_FPS || fpsFlag > IOS_DEVICE_RECORD_MAX_FPS) ) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `fps must be an integer between ${IOS_DEVICE_RECORD_MIN_FPS} and ${IOS_DEVICE_RECORD_MAX_FPS}`, - }, - }; + return errorResponse( + 'INVALID_ARGS', + `fps must be an integer between ${IOS_DEVICE_RECORD_MIN_FPS} and ${IOS_DEVICE_RECORD_MAX_FPS}`, + ); } if (!isCommandSupportedOnDevice('record', device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'record is not supported on this device', - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'); } const outPath = req.positionals?.[1] ?? `./recording-${Date.now()}.mp4`; @@ -220,14 +206,10 @@ async function startRecording(params: { if (device.platform === 'ios' && device.kind === 'device') { const appBundleId = normalizeAppBundleId(activeSession); if (!appBundleId) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: - 'record on physical iOS devices requires an active app session; run open first', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'record on physical iOS devices requires an active app session; run open first', + ); } recording = await startIosDeviceRecording({ req, @@ -243,13 +225,10 @@ async function startRecording(params: { } else if (device.platform === 'macos') { const appBundleId = normalizeAppBundleId(activeSession); if (!appBundleId) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'record on macOS requires an active app session; run open first', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'record on macOS requires an active app session; run open first', + ); } recording = await startMacOsRecording({ req, @@ -314,36 +293,18 @@ async function stopNonRunnerRecording(params: { const stopResult = await recording.wait; if (stopResult.exitCode !== 0) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'simctl recordVideo')}`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'simctl recordVideo')}`, + ); } - const telemetryPath = persistRecordingTelemetry({ + await finalizeRecordingOverlay({ recording, + deps, + targetLabel: 'iOS recording', }); - if (recording.showTouches) { - const overlaySupportWarning = getRecordingOverlaySupportWarning(); - if (overlaySupportWarning) { - recording.overlayWarning = overlaySupportWarning; - } else { - try { - await deps.overlayRecordingTouches({ - videoPath: recording.outPath, - telemetryPath, - targetLabel: 'iOS recording', - }); - } catch (error) { - recording.overlayWarning = `failed to overlay recording touches: ${formatRecordTraceError(error)}`; - } - } - } - return null; } @@ -357,7 +318,7 @@ async function stopRecording(params: { const { req, activeSession, device, logPath, deps } = params; if (!activeSession.recording) { - return { ok: false, error: { code: 'INVALID_ARGS', message: 'no active recording' } }; + return errorResponse('INVALID_ARGS', 'no active recording'); } const recording = activeSession.recording; @@ -375,13 +336,7 @@ async function stopRecording(params: { } if (invalidatedReason) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: invalidatedReason, - }, - }; + return errorResponse('COMMAND_FAILED', invalidatedReason); } return buildRecordStopResponse(recording); @@ -456,7 +411,7 @@ export async function handleRecordCommand(params: { const action = (req.positionals?.[0] ?? '').toLowerCase(); if (!['start', 'stop'].includes(action)) { - return { ok: false, error: { code: 'INVALID_ARGS', message: 'record requires start|stop' } }; + return errorResponse('INVALID_ARGS', 'record requires start|stop'); } if (action === 'start') { diff --git a/src/daemon/handlers/response.ts b/src/daemon/handlers/response.ts index cdaa0e6da..7ea2681b5 100644 --- a/src/daemon/handlers/response.ts +++ b/src/daemon/handlers/response.ts @@ -1,10 +1,12 @@ import type { DaemonResponse } from '../types.ts'; +export type DaemonFailureResponse = Extract; + export function errorResponse( code: string, message: string, details?: Record, -): DaemonResponse { +): DaemonFailureResponse { return { ok: false, error: { code, message, ...(details ? { details } : {}) }, diff --git a/src/daemon/handlers/session-batch.ts b/src/daemon/handlers/session-batch.ts index 8afff1d67..0eb135eae 100644 --- a/src/daemon/handlers/session-batch.ts +++ b/src/daemon/handlers/session-batch.ts @@ -7,16 +7,8 @@ import { import type { BatchStep, CommandFlags } from '../../core/dispatch.ts'; import { asAppError } from '../../utils/errors.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; - -const BATCH_PARENT_FLAG_KEYS: Array = [ - 'platform', - 'target', - 'device', - 'udid', - 'serial', - 'verbose', - 'out', -]; +import { mergeParentFlags } from './handler-utils.ts'; +import { errorResponse } from './response.ts'; export async function runBatchCommands( req: DaemonRequest, @@ -25,23 +17,14 @@ export async function runBatchCommands( ): Promise { const batchOnError = req.flags?.batchOnError ?? 'stop'; if (batchOnError !== 'stop') { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `Unsupported batch on-error mode: ${batchOnError}.`, - }, - }; + return errorResponse('INVALID_ARGS', `Unsupported batch on-error mode: ${batchOnError}.`); } const batchMaxSteps = req.flags?.batchMaxSteps ?? DEFAULT_BATCH_MAX_STEPS; if (!Number.isInteger(batchMaxSteps) || batchMaxSteps < 1 || batchMaxSteps > 1000) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `Invalid batch max-steps: ${String(req.flags?.batchMaxSteps)}`, - }, - }; + return errorResponse( + 'INVALID_ARGS', + `Invalid batch max-steps: ${String(req.flags?.batchMaxSteps)}`, + ); } try { const steps = validateAndNormalizeBatchSteps(req.flags?.batchSteps, batchMaxSteps); @@ -84,10 +67,7 @@ export async function runBatchCommands( }; } catch (error) { const appErr = asAppError(error); - return { - ok: false, - error: { code: appErr.code, message: appErr.message, details: appErr.details }, - }; + return errorResponse(appErr.code, appErr.message, appErr.details); } } @@ -153,12 +133,5 @@ function buildBatchStepFlags( batchMaxSteps: _batchMaxSteps, ...merged } = stepFlags ?? {}; - const parentRecord = (parentFlags ?? {}) as Record; - const mergedRecord = merged as Record; - for (const key of BATCH_PARENT_FLAG_KEYS) { - if (mergedRecord[key] === undefined && parentRecord[key] !== undefined) { - mergedRecord[key] = parentRecord[key]; - } - } - return merged as CommandFlags; + return mergeParentFlags(parentFlags, merged as CommandFlags); } diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 4c236d6b0..fca806229 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -19,6 +19,7 @@ import { isIosSimulator, settleIosSimulator, } from './session-device-utils.ts'; +import { errorResponse } from './response.ts'; async function shutdownAndroidEmulator(device: DeviceInfo): Promise<{ success: boolean; @@ -102,7 +103,7 @@ export async function handleCloseCommand(params: { const { req, sessionName, logPath, sessionStore } = params; const session = sessionStore.get(sessionName); if (!session) { - return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } }; + return errorResponse('SESSION_NOT_FOUND', 'No active session'); } if (session.appLog) { await stopAppLog(session.appLog); diff --git a/src/daemon/handlers/session-device-utils.ts b/src/daemon/handlers/session-device-utils.ts index f5799bd5b..4280b457d 100644 --- a/src/daemon/handlers/session-device-utils.ts +++ b/src/daemon/handlers/session-device-utils.ts @@ -8,6 +8,7 @@ import { resolveTimeoutMs } from '../../utils/timeouts.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { resolveTargetDevice } from '../../core/dispatch.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { errorResponse } from './response.ts'; export const IOS_SIMULATOR_POST_CLOSE_SETTLE_MS = resolveTimeoutMs( process.env.AGENT_DEVICE_IOS_SIMULATOR_POST_CLOSE_SETTLE_MS, @@ -29,13 +30,10 @@ export function requireSessionOrExplicitSelector( if (session || hasExplicitDeviceSelector(flags)) { return null; } - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `${command} requires an active session or an explicit device selector (e.g. --platform ios).`, - }, - }; + return errorResponse( + 'INVALID_ARGS', + `${command} requires an active session or an explicit device selector (e.g. --platform ios).`, + ); } export function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean { diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 646499ec9..ac8a74a2b 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -15,6 +15,7 @@ import { SessionStore } from '../session-store.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { ensureSimulatorExists } from '../../platforms/ios/ensure-simulator.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; +import { errorResponse } from './response.ts'; export async function handleSessionInventoryCommands(params: { req: DaemonRequest; @@ -52,10 +53,7 @@ export async function handleSessionInventoryCommands(params: { const runtime = flags.runtime; const iosSimulatorSetPath = resolveIosSimulatorDeviceSetPath(flags.iosSimulatorDeviceSet); if (!deviceName) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'ensure-simulator requires --device ' }, - }; + return errorResponse('INVALID_ARGS', 'ensure-simulator requires --device '); } const result = await ensureSimulatorExists({ @@ -79,10 +77,7 @@ export async function handleSessionInventoryCommands(params: { }; } catch (err) { const appErr = asAppError(err); - return { - ok: false, - error: { code: appErr.code, message: appErr.message, details: appErr.details }, - }; + return errorResponse(appErr.code, appErr.message, appErr.details); } } @@ -138,10 +133,7 @@ export async function handleSessionInventoryCommands(params: { return { ok: true, data: { devices: publicDevices } }; } catch (err) { const appErr = asAppError(err); - return { - ok: false, - error: { code: appErr.code, message: appErr.message, details: appErr.details }, - }; + return errorResponse(appErr.code, appErr.message, appErr.details); } } @@ -157,10 +149,7 @@ export async function handleSessionInventoryCommands(params: { ensureReady: true, }); if (!isCommandSupportedOnDevice('apps', device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'apps is not supported on this device'); } const appsFilter = req.flags?.appsFilter ?? 'all'; diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 8b58f163c..846117dfb 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -1,5 +1,5 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { AppError, normalizeError } from '../../utils/errors.ts'; +import { normalizeError } from '../../utils/errors.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { @@ -12,6 +12,7 @@ import { stopAppLog, } from '../app-log.ts'; import { buildPerfResponseData } from './session-perf.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; const LOG_ACTIONS = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const; const LOG_ACTIONS_MESSAGE = `logs requires ${LOG_ACTIONS.slice(0, -1).join(', ')}, or ${LOG_ACTIONS.at(-1)}`; @@ -69,13 +70,7 @@ async function handlePerfCommand(params: ObservabilityParams): Promise { if (session.appLog && !restart) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'logs clear requires logs to be stopped first; run logs stop', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'logs clear requires logs to be stopped first; run logs stop', + ); } if (restart && !session.appBundleId) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'logs clear --restart requires an app session; run open first', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'logs clear --restart requires an app session; run open first', + ); } const logPath = sessionStore.resolveAppLogPath(sessionName); @@ -265,22 +240,13 @@ async function handleLogsStart( sessionStore: SessionStore, ): Promise { if (session.appLog) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'app log already streaming; run logs stop first', - }, - }; + return errorResponse('INVALID_ARGS', 'app log already streaming; run logs stop first'); } if (!session.appBundleId) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'logs start requires an app session; run open first', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'logs start requires an app session; run open first', + ); } const appLogPath = sessionStore.resolveAppLogPath(sessionName); @@ -316,7 +282,7 @@ async function handleLogsStop( sessionStore: SessionStore, ): Promise { if (!session.appLog) { - return { ok: false, error: { code: 'INVALID_ARGS', message: 'no app log stream active' } }; + return errorResponse('INVALID_ARGS', 'no app log stream active'); } const outPath = session.appLog.outPath; await stopAppLog(session.appLog); @@ -332,38 +298,24 @@ async function handleNetworkCommand(params: ObservabilityParams): Promise 200) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'network dump limit must be an integer in range 1..200', - }, - }; + return errorResponse('INVALID_ARGS', 'network dump limit must be an integer in range 1..200'); } const includeValidation = resolveNetworkIncludeMode(req); - if (!includeValidation.ok) return includeValidation.response; + if (!includeValidation.ok) return includeValidation; const { include } = includeValidation; const capture = await readSessionNetworkCapture({ @@ -392,28 +344,18 @@ async function handleNetworkCommand(params: ObservabilityParams): Promise>(); @@ -150,14 +151,7 @@ async function completeOpenCommand(params: { await settleIosSimulator(device, IOS_SIMULATOR_POST_OPEN_SETTLE_MS); if (isRequestCanceled(req.meta?.requestId)) { const canceled = createRequestCanceledError(); - return { - ok: false, - error: { - code: canceled.code, - message: canceled.message, - details: canceled.details, - }, - }; + return errorResponse(canceled.code, canceled.message, canceled.details); } if (existingSession) { @@ -209,13 +203,7 @@ export async function handleOpenCommand(params: { if (sessionStore.has(sessionName)) { const session = sessionStore.get(sessionName); if (!session) { - return { - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: `Session "${sessionName}" not found.`, - }, - }; + return errorResponse('SESSION_NOT_FOUND', `Session "${sessionName}" not found.`); } const shouldRelaunch = req.flags?.relaunch === true; const requestedOpenTarget = req.positionals?.[0]; @@ -315,14 +303,11 @@ export async function handleOpenCommand(params: { .toArray() .find((activeSession) => activeSession.device.id === device.id); if (inUse) { - return { - ok: false, - error: { - code: 'DEVICE_IN_USE', - message: `Device is already in use by session "${inUse.name}".`, - details: { session: inUse.name, deviceId: device.id, deviceName: device.name }, - }, - }; + return errorResponse( + 'DEVICE_IN_USE', + `Device is already in use by session "${inUse.name}".`, + { session: inUse.name, deviceId: device.id, deviceName: device.name }, + ); } const details = await prepareOpenCommandDetails({ diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 65c16f309..4912f8d09 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -6,16 +6,8 @@ import { SessionStore } from '../session-store.ts'; import { parseReplayScript, writeReplayScript } from './session-replay-script.ts'; import { healReplayAction } from './session-replay-heal.ts'; import { formatScriptActionSummary } from '../script-utils.ts'; - -const REPLAY_PARENT_FLAG_KEYS: Array = [ - 'platform', - 'target', - 'device', - 'udid', - 'serial', - 'verbose', - 'out', -]; +import { mergeParentFlags } from './handler-utils.ts'; +import { errorResponse } from './response.ts'; export async function runReplayScriptFile(params: { req: DaemonRequest; @@ -27,7 +19,7 @@ export async function runReplayScriptFile(params: { const { req, sessionName, logPath, sessionStore, invoke } = params; const filePath = req.positionals?.[0]; if (!filePath) { - return { ok: false, error: { code: 'INVALID_ARGS', message: 'replay requires a path' } }; + return errorResponse('INVALID_ARGS', 'replay requires a path'); } let resolved = ''; @@ -37,13 +29,10 @@ export async function runReplayScriptFile(params: { const script = fs.readFileSync(resolved, 'utf8'); const firstNonWhitespace = script.trimStart()[0]; if (firstNonWhitespace === '{' || firstNonWhitespace === '[') { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'replay accepts .ad script files. JSON replay payloads are no longer supported.', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'replay accepts .ad script files. JSON replay payloads are no longer supported.', + ); } const actions = parseReplayScript(script); @@ -105,14 +94,11 @@ export async function runReplayScriptFile(params: { }; } catch (err) { const appErr = asAppError(err); - return { - ok: false, - error: { - code: appErr.code, - message: appErr.message, - details: artifactPaths.size > 0 ? { artifactPaths: [...artifactPaths] } : undefined, - }, - }; + return errorResponse( + appErr.code, + appErr.message, + artifactPaths.size > 0 ? { artifactPaths: [...artifactPaths] } : undefined, + ); } } @@ -195,13 +181,5 @@ export function buildReplayActionFlags( parentFlags: CommandFlags | undefined, actionFlags: SessionAction['flags'] | undefined, ): CommandFlags { - const merged: CommandFlags = { ...(actionFlags ?? {}) }; - const mergedRecord = merged as Record; - const parentRecord = (parentFlags ?? {}) as Record; - for (const key of REPLAY_PARENT_FLAG_KEYS) { - if (mergedRecord[key] === undefined && parentRecord[key] !== undefined) { - mergedRecord[key] = parentRecord[key]; - } - } - return merged; + return mergeParentFlags(parentFlags, { ...(actionFlags ?? {}) }); } diff --git a/src/daemon/handlers/session-runtime-command.ts b/src/daemon/handlers/session-runtime-command.ts index 65941ae2b..926635e14 100644 --- a/src/daemon/handlers/session-runtime-command.ts +++ b/src/daemon/handlers/session-runtime-command.ts @@ -2,6 +2,7 @@ import { normalizePlatformSelector } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; +import { errorResponse } from './response.ts'; import { buildRuntimeHints, countConfiguredRuntimeHints, @@ -19,10 +20,7 @@ export async function handleRuntimeCommand(params: { const session = sessionStore.get(sessionName); const current = sessionStore.getRuntimeHints(sessionName); if (!['set', 'show', 'clear'].includes(action)) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'runtime requires set, show, or clear' }, - }; + return errorResponse('INVALID_ARGS', 'runtime requires set, show, or clear'); } if (action === 'clear') { if (hasRuntimeTransportHints(current) && session?.appBundleId) { @@ -49,34 +47,23 @@ export async function handleRuntimeCommand(params: { normalizePlatformSelector(req.flags?.platform) ?? current?.platform ?? session?.device.platform, ); if (!platform) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: - 'runtime set only supports iOS and Android sessions. Pass --platform ios|android or open an iOS/Android session first.', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'runtime set only supports iOS and Android sessions. Pass --platform ios|android or open an iOS/Android session first.', + ); } if (session && session.device.platform !== platform) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `runtime set targets ${platform}, but session "${sessionName}" is already bound to ${session.device.platform}.`, - }, - }; + return errorResponse( + 'INVALID_ARGS', + `runtime set targets ${platform}, but session "${sessionName}" is already bound to ${session.device.platform}.`, + ); } const nextRuntime = mergeRuntimeHints(current, buildRuntimeHints(req.flags, platform)); if (countConfiguredRuntimeHints(nextRuntime) === 0) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: - 'runtime set requires at least one hint such as --metro-host, --metro-port, --bundle-url, or --launch-url.', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'runtime set requires at least one hint such as --metro-host, --metro-port, --bundle-url, or --launch-url.', + ); } sessionStore.setRuntimeHints(sessionName, nextRuntime); return { diff --git a/src/daemon/handlers/session-runtime.ts b/src/daemon/handlers/session-runtime.ts index a78535e9d..09fe218ea 100644 --- a/src/daemon/handlers/session-runtime.ts +++ b/src/daemon/handlers/session-runtime.ts @@ -1,9 +1,10 @@ import { AppError, asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { CommandFlags } from '../../core/dispatch.ts'; -import type { DaemonRequest, DaemonResponse, SessionRuntimeHints, SessionState } from '../types.ts'; +import type { DaemonRequest, SessionRuntimeHints, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; const RUNTIME_HINT_FIELD_NAMES = [ 'platform', @@ -219,9 +220,7 @@ function resolveOpenRuntimeHints(params: { export function tryResolveOpenRuntimeHints( params: Parameters[0], -): - | { ok: true; data: ReturnType } - | { ok: false; response: DaemonResponse } { +): { ok: true; data: ReturnType } | DaemonFailureResponse { try { return { ok: true, @@ -229,17 +228,7 @@ export function tryResolveOpenRuntimeHints( }; } catch (error) { const appErr = asAppError(error); - return { - ok: false, - response: { - ok: false, - error: { - code: appErr.code, - message: appErr.message, - details: appErr.details, - }, - }, - }; + return errorResponse(appErr.code, appErr.message, appErr.details); } } diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index f6733f0ce..4b8ea9d42 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -11,6 +11,7 @@ import { resolveCommandDevice, selectorTargetsSessionDevice, } from './session-device-utils.ts'; +import { errorResponse } from './response.ts'; async function ensureAndroidEmulatorBoot(params: { avdName: string; @@ -41,10 +42,7 @@ async function handleAppStateCommand(params: { normalizedPlatform === 'ios' ? `No active session "${sessionName}". Run open with --session ${sessionName} first.` : `No active session "${sessionName}". Run open with --session ${sessionName} first, or omit --session to query by device selector.`; - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message }, - }; + return errorResponse('SESSION_NOT_FOUND', message); } const guard = requireSessionOrExplicitSelector('appstate', session, flags); @@ -57,22 +55,10 @@ async function handleAppStateCommand(params: { const targetsMacOs = normalizedPlatform === 'macos'; if (targetsIos && !shouldUseSessionStateForApple) { - return { - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE, - }, - }; + return errorResponse('SESSION_NOT_FOUND', IOS_APPSTATE_SESSION_REQUIRED_MESSAGE); } if (targetsMacOs && !shouldUseSessionStateForApple) { - return { - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE, - }, - }; + return errorResponse('SESSION_NOT_FOUND', MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE); } if (shouldUseSessionStateForApple && session) { @@ -97,13 +83,10 @@ async function handleAppStateCommand(params: { } const sessionPlatform = session.device.platform === 'macos' ? 'macOS' : 'iOS'; - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `No foreground app is tracked for this ${sessionPlatform} session. Open an app in the session, then retry appstate.`, - }, - }; + return errorResponse( + 'COMMAND_FAILED', + `No foreground app is tracked for this ${sessionPlatform} session. Open an app in the session, then retry appstate.`, + ); } return { @@ -130,22 +113,10 @@ async function handleAppStateCommand(params: { ensureReady: true, }); if (device.platform === 'ios') { - return { - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: IOS_APPSTATE_SESSION_REQUIRED_MESSAGE, - }, - }; + return errorResponse('SESSION_NOT_FOUND', IOS_APPSTATE_SESSION_REQUIRED_MESSAGE); } if (device.platform === 'macos') { - return { - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE, - }, - }; + return errorResponse('SESSION_NOT_FOUND', MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE); } const { getAndroidAppState } = await import('../../platforms/android/index.ts'); @@ -178,13 +149,10 @@ export async function handleSessionStateCommands(params: { const targetsAndroid = normalizedPlatform === 'android'; const wantsAndroidHeadless = flags.headless === true; if (wantsAndroidHeadless && !targetsAndroid) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'boot --headless is supported only for Android emulators.', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'boot --headless is supported only for Android emulators.', + ); } const fallbackAvdName = resolveAndroidEmulatorAvdName({ @@ -209,14 +177,10 @@ export async function handleSessionStateCommands(params: { !fallbackAvdName && appErr.code === 'DEVICE_NOT_FOUND' ) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: - 'boot --headless requires --device (or an Android emulator session target).', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'boot --headless requires --device (or an Android emulator session target).', + ); } if ( !canFallbackLaunchAndroidEmulator || @@ -234,24 +198,18 @@ export async function handleSessionStateCommands(params: { } if (flags.target && (device.target ?? 'mobile') !== flags.target) { - return { - ok: false, - error: { - code: 'DEVICE_NOT_FOUND', - message: `No ${device.platform} device found matching --target ${flags.target}.`, - }, - }; + return errorResponse( + 'DEVICE_NOT_FOUND', + `No ${device.platform} device found matching --target ${flags.target}.`, + ); } if (targetsAndroid && wantsAndroidHeadless) { if (device.platform !== 'android' || device.kind !== 'emulator') { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'boot --headless is supported only for Android emulators.', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'boot --headless is supported only for Android emulators.', + ); } if (!launchedAndroidEmulator) { const avdName = resolveAndroidEmulatorAvdName({ @@ -260,14 +218,10 @@ export async function handleSessionStateCommands(params: { resolvedDevice: device, }); if (!avdName) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: - 'boot --headless requires --device (or an Android emulator session target).', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'boot --headless requires --device (or an Android emulator session target).', + ); } device = await ensureAndroidEmulatorBoot({ avdName, @@ -284,10 +238,7 @@ export async function handleSessionStateCommands(params: { } if (!isCommandSupportedOnDevice('boot', device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'boot is not supported on this device'); } return { diff --git a/src/daemon/handlers/session-test-runtime.ts b/src/daemon/handlers/session-test-runtime.ts index 8926ec360..20f8e95ab 100644 --- a/src/daemon/handlers/session-test-runtime.ts +++ b/src/daemon/handlers/session-test-runtime.ts @@ -1,6 +1,7 @@ import { setTimeout as sleep } from 'node:timers/promises'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { asAppError } from '../../utils/errors.ts'; +import { errorResponse } from './response.ts'; import { clearRequestCanceled, markRequestCanceled, @@ -43,10 +44,7 @@ export async function runReplayTestAttempt(params: { }) .catch((error) => { const appErr = asAppError(error); - return { - ok: false, - error: { code: appErr.code, message: appErr.message }, - } as DaemonResponse; + return errorResponse(appErr.code, appErr.message); }) .finally(() => { clearRequestCanceled(requestId); diff --git a/src/daemon/handlers/session-test.ts b/src/daemon/handlers/session-test.ts index 32a06bbb7..7703cc04a 100644 --- a/src/daemon/handlers/session-test.ts +++ b/src/daemon/handlers/session-test.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { asAppError } from '../../utils/errors.ts'; +import { errorResponse } from './response.ts'; import type { DaemonRequest, DaemonResponse, @@ -38,10 +39,7 @@ export async function runReplayTestSuite(params: { }): Promise { const { req, sessionName, runReplay, cleanupSession } = params; if ((req.positionals?.length ?? 0) === 0) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'test requires at least one path or glob' }, - }; + return errorResponse('INVALID_ARGS', 'test requires at least one path or glob'); } try { @@ -96,7 +94,7 @@ export async function runReplayTestSuite(params: { return { ok: true, data }; } catch (err) { const appErr = asAppError(err); - return { ok: false, error: { code: appErr.code, message: appErr.message } }; + return errorResponse(appErr.code, appErr.message); } } diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 7830dfebc..1e96a3d1c 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -11,6 +11,7 @@ import { handleReleaseMaterializedPathsCommand, } from './install-source.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; +import { errorResponse } from './response.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; import { handleOpenCommand } from './session-open.ts'; import { @@ -69,13 +70,7 @@ async function runSessionOrSelectorDispatch(params: { ensureReady: true, }); if (!isCommandSupportedOnDevice(command, device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: `${command} is not supported on this device`, - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', `${command} is not supported on this device`); } const result = await dispatchCommand(device, command, positionals, req.flags?.out, { @@ -112,13 +107,7 @@ async function handleClipboardCommand(params: { const action = (req.positionals?.[0] ?? '').toLowerCase(); if (action !== 'read' && action !== 'write') { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'clipboard requires a subcommand: read or write', - }, - }; + return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); } const device = await resolveCommandDevice({ @@ -127,13 +116,7 @@ async function handleClipboardCommand(params: { ensureReady: true, }); if (!isCommandSupportedOnDevice('clipboard', device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'clipboard is not supported on this device', - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'clipboard is not supported on this device'); } const result = await dispatchCommand(device, 'clipboard', req.positionals ?? [], req.flags?.out, { @@ -199,14 +182,10 @@ export async function handleSessionCommands(params: { const flags = req.flags ?? {}; const normalizedPlatform = normalizePlatformSelector(flags.platform); if (normalizedPlatform === 'ios') { - return { - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: - 'iOS keyboard dismiss requires an active session so the target app stays foregrounded. Run open first.', - }, - }; + return errorResponse( + 'SESSION_NOT_FOUND', + 'iOS keyboard dismiss requires an active session so the target app stays foregrounded. Run open first.', + ); } } return await runSessionOrSelectorDispatch({ @@ -253,13 +232,10 @@ export async function handleSessionCommands(params: { const appId = req.positionals?.[0]?.trim(); const payloadArg = req.positionals?.[1]?.trim(); if (!appId || !payloadArg) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'push requires ', - }, - }; + return errorResponse( + 'INVALID_ARGS', + 'push requires ', + ); } return await runSessionOrSelectorDispatch({ diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index c5b1563bc..77a8bd8af 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -6,6 +6,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { recordIfSession } from './snapshot-session.ts'; import { DEFAULT_TIMEOUT_MS, parseTimeout, POLL_INTERVAL_MS } from './parse-utils.ts'; +import { errorResponse } from './response.ts'; type HandleAlertCommandParams = { req: DaemonRequest; @@ -34,13 +35,7 @@ export async function handleAlertCommand( }; })(); if (!isCommandSupportedOnDevice('alert', device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'alert is not supported on this device', - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'alert is not supported on this device'); } if (device.platform === 'macos') { const runMacOsAlert = async () => @@ -61,7 +56,7 @@ export async function handleAlertCommand( } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } }; + return errorResponse('COMMAND_FAILED', 'alert wait timed out'); } const resolvedAction = action === 'accept' || action === 'dismiss' ? action : 'get'; if (resolvedAction === 'accept' || resolvedAction === 'dismiss') { @@ -108,7 +103,7 @@ export async function handleAlertCommand( } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } }; + return errorResponse('COMMAND_FAILED', 'alert wait timed out'); } const resolvedAction = diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index c93e66a8b..f115e535c 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -13,7 +13,7 @@ import { } from '../../utils/snapshot.ts'; import { normalizeSnapshotTree } from '../../utils/snapshot-tree.ts'; import { buildMobileSnapshotPresentation } from '../../utils/mobile-snapshot-semantics.ts'; -import type { DaemonResponse, SessionState } from '../types.ts'; +import type { SessionState } from '../types.ts'; import { ANDROID_FRESHNESS_RETRY_DELAYS_MS, clearAndroidSnapshotFreshness, @@ -25,6 +25,7 @@ import { } from '../android-snapshot-freshness.ts'; import { contextFromFlags } from '../context.ts'; import { findNodeByLabel, pruneGroupNodes, resolveRefLabel } from '../snapshot-processing.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; function isDesktopBackend(backend: SnapshotBackend | undefined): boolean { return backend === 'macos-helper' || backend === 'linux-atspi'; @@ -367,45 +368,21 @@ function isInteractiveSnapshotNode(node: RawSnapshotNode): boolean { export function resolveSnapshotScope( snapshotScope: string | undefined, session: SessionState | undefined, -): { ok: true; scope?: string } | { ok: false; response: DaemonResponse } { +): { ok: true; scope?: string } | DaemonFailureResponse { if (!snapshotScope || !snapshotScope.trim().startsWith('@')) { return { ok: true, scope: snapshotScope }; } if (!session?.snapshot) { - return { - ok: false, - response: { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'Ref scope requires an existing snapshot in session.', - }, - }, - }; + return errorResponse('INVALID_ARGS', 'Ref scope requires an existing snapshot in session.'); } const ref = normalizeRef(snapshotScope.trim()); if (!ref) { - return { - ok: false, - response: { - ok: false, - error: { code: 'INVALID_ARGS', message: `Invalid ref scope: ${snapshotScope}` }, - }, - }; + return errorResponse('INVALID_ARGS', `Invalid ref scope: ${snapshotScope}`); } const node = findNodeByRef(session.snapshot.nodes, ref); const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined; if (!resolved) { - return { - ok: false, - response: { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Ref ${snapshotScope} not found or has no label`, - }, - }, - }; + return errorResponse('COMMAND_FAILED', `Ref ${snapshotScope} not found or has no label`); } return { ok: true, scope: resolved }; } diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index bf38dfe4f..b9ea242f3 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -9,6 +9,7 @@ import { contextFromFlags } from '../context.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { recordIfSession } from './snapshot-session.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; type ParsedSettingsArgs = { setting: string; @@ -27,21 +28,12 @@ type HandleSettingsCommandParams = { export function parseSettingsArgs( req: DaemonRequest, -): { ok: true; parsed: ParsedSettingsArgs } | { ok: false; response: DaemonResponse } { +): { ok: true; parsed: ParsedSettingsArgs } | DaemonFailureResponse { const setting = req.positionals?.[0]?.toLowerCase(); const state = req.positionals?.[1]?.toLowerCase(); const permissionTarget = req.positionals?.[2]?.toLowerCase(); if (!setting || !state || (setting === 'permission' && !permissionTarget)) { - return { - ok: false, - response: { - ok: false, - error: { - code: 'INVALID_ARGS', - message: SETTINGS_INVALID_ARGS_MESSAGE, - }, - }, - }; + return errorResponse('INVALID_ARGS', SETTINGS_INVALID_ARGS_MESSAGE); } return { ok: true, parsed: { setting, state, permissionTarget } }; } @@ -52,22 +44,10 @@ export async function handleSettingsCommand( const { req, logPath, sessionStore, session, device, parsed } = params; const { setting, state, permissionTarget } = parsed; if (!isCommandSupportedOnDevice('settings', device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'settings is not supported on this device', - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'settings is not supported on this device'); } if (device.platform === 'macos' && !isMacOsSettingSupported(setting)) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: getUnsupportedMacOsSettingMessage(setting), - }, - }; + return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); } const appBundleId = session?.appBundleId; diff --git a/src/daemon/handlers/snapshot-wait.ts b/src/daemon/handlers/snapshot-wait.ts index bacfaf48e..fb4868921 100644 --- a/src/daemon/handlers/snapshot-wait.ts +++ b/src/daemon/handlers/snapshot-wait.ts @@ -14,7 +14,7 @@ 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 } from './response.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; export type WaitParsed = | { kind: 'sleep'; durationMs: number } @@ -101,7 +101,7 @@ export async function handleWaitCommand(params: HandleWaitCommandParams): Promis } const textResult = resolveWaitText(parsed, session); - if (!textResult.ok) return textResult.response; + if (!textResult.ok) return textResult; return await waitForText({ device, logPath, @@ -154,52 +154,25 @@ async function waitForSelector(params: { function resolveWaitText( parsed: Exclude, session: SessionState | undefined, -): { ok: true; text: string; timeoutMs: number | null } | { ok: false; response: DaemonResponse } { +): { ok: true; text: string; timeoutMs: number | null } | DaemonFailureResponse { if (parsed.kind === 'ref') { if (!session?.snapshot) { - return { - ok: false, - response: { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'Ref wait requires an existing snapshot in session.', - }, - }, - }; + return errorResponse('INVALID_ARGS', 'Ref wait requires an existing snapshot in session.'); } const ref = normalizeRef(parsed.rawRef); if (!ref) { - return { - ok: false, - response: { - ok: false, - error: { code: 'INVALID_ARGS', message: `Invalid ref: ${parsed.rawRef}` }, - }, - }; + 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 { - ok: false, - response: { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Ref ${parsed.rawRef} not found or has no label`, - }, - }, - }; + 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 { - ok: false, - response: { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } }, - }; + return errorResponse('INVALID_ARGS', 'wait requires text'); } return { ok: true, text: parsed.text, timeoutMs: parsed.timeoutMs }; } diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index e589decfb..95d3769d1 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -2,6 +2,7 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { buildSnapshotDiff, countSnapshotComparableLines } from '../snapshot-diff.ts'; +import { errorResponse } from './response.ts'; import { buildSnapshotVisibility, captureSnapshot, @@ -39,16 +40,10 @@ export async function handleSnapshotCommands(params: { if (command === 'snapshot') { const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); if (!isCommandSupportedOnDevice('snapshot', device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'snapshot is not supported on this device', - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'snapshot is not supported on this device'); } const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); - if (!resolvedScope.ok) return resolvedScope.response; + if (!resolvedScope.ok) return resolvedScope; return await withSessionlessRunnerCleanup(session, device, async () => { const capture = await captureSnapshot({ @@ -99,13 +94,7 @@ export async function handleSnapshotCommands(params: { if (command === 'diff') { if (req.positionals?.[0] !== 'snapshot') { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'diff currently supports only: diff snapshot', - }, - }; + return errorResponse('INVALID_ARGS', 'diff currently supports only: diff snapshot'); } return await handleSnapshotDiffRequest({ req, sessionName, logPath, sessionStore }); } @@ -114,10 +103,7 @@ export async function handleSnapshotCommands(params: { const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); const parsed = parseWaitArgs(req.positionals ?? []); if (!parsed) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'wait requires a duration or text' }, - }; + return errorResponse('INVALID_ARGS', 'wait requires a duration or text'); } const executeWait = () => handleWaitCommand({ @@ -150,7 +136,7 @@ export async function handleSnapshotCommands(params: { if (command === 'settings') { const parsedSettings = parseSettingsArgs(req); - if (!parsedSettings.ok) return parsedSettings.response; + if (!parsedSettings.ok) return parsedSettings; const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); return await withSessionlessRunnerCleanup(session, device, async () => { return await handleSettingsCommand({ @@ -236,16 +222,10 @@ async function handleSnapshotDiffRequest(params: { const { req, sessionName, logPath, sessionStore } = params; const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); if (!isCommandSupportedOnDevice('diff', device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'diff is not supported on this device', - }, - }; + return errorResponse('UNSUPPORTED_OPERATION', 'diff is not supported on this device'); } const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); - if (!resolvedScope.ok) return resolvedScope.response; + if (!resolvedScope.ok) return resolvedScope; const flattenForDiff = req.flags?.snapshotInteractiveOnly === true; return await withSessionlessRunnerCleanup(session, device, async () => { diff --git a/src/daemon/selectors-match.ts b/src/daemon/selectors-match.ts index d16416f50..41c6a1c46 100644 --- a/src/daemon/selectors-match.ts +++ b/src/daemon/selectors-match.ts @@ -1,6 +1,7 @@ import type { Platform } from '../utils/device.ts'; import type { SnapshotNode } from '../utils/snapshot.ts'; import { extractNodeText, isFillableType, normalizeType } from './snapshot-processing.ts'; +import { normalizeText } from '../utils/finders.ts'; import type { Selector, SelectorTerm } from './selectors-parse.ts'; export function matchesSelector( @@ -57,7 +58,3 @@ function matchesTerm(node: SnapshotNode, term: SelectorTerm, platform: Platform) function textEquals(value: string | undefined, query: string): boolean { return normalizeText(value ?? '') === normalizeText(query); } - -function normalizeText(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, ' '); -} diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index be1fb586b..3a99f45f6 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -216,72 +216,48 @@ export class SessionStore { } } +const SANITIZED_FLAG_KEYS = [ + 'platform', + 'device', + 'udid', + 'serial', + 'out', + 'verbose', + 'metroHost', + 'metroPort', + 'bundleUrl', + 'launchUrl', + 'snapshotInteractiveOnly', + 'snapshotCompact', + 'snapshotDepth', + 'snapshotScope', + 'snapshotRaw', + 'screenshotFullscreen', + 'relaunch', + 'saveScript', + 'noRecord', + 'fps', + 'hideTouches', + 'count', + 'intervalMs', + 'delayMs', + 'holdMs', + 'jitterPx', + 'doubleTap', + 'clickButton', + 'pauseMs', + 'pattern', +] as const; + function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] { if (!flags) return {}; - const { - platform, - device, - udid, - serial, - out, - verbose, - metroHost, - metroPort, - bundleUrl, - launchUrl, - snapshotInteractiveOnly, - snapshotCompact, - snapshotDepth, - snapshotScope, - snapshotRaw, - screenshotFullscreen, - relaunch, - saveScript, - noRecord, - fps, - hideTouches, - count, - intervalMs, - delayMs, - holdMs, - jitterPx, - doubleTap, - clickButton, - pauseMs, - pattern, - } = flags; - return { - platform, - device, - udid, - serial, - out, - verbose, - metroHost, - metroPort, - bundleUrl, - launchUrl, - snapshotInteractiveOnly, - snapshotCompact, - snapshotDepth, - snapshotScope, - snapshotRaw, - screenshotFullscreen, - relaunch, - saveScript, - noRecord, - fps, - hideTouches, - count, - intervalMs, - delayMs, - holdMs, - jitterPx, - doubleTap, - clickButton, - pauseMs, - pattern, - }; + const result: Record = {}; + for (const key of SANITIZED_FLAG_KEYS) { + if (flags[key] !== undefined) { + result[key] = flags[key]; + } + } + return result as SessionAction['flags']; } function formatScript(session: SessionState, actions: SessionAction[]): string { diff --git a/src/metro.ts b/src/metro.ts index 69a98d292..1532697c1 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -5,6 +5,7 @@ export { buildBundleUrl, normalizeBaseUrl } from './utils/url.ts'; type EnvSource = NodeJS.ProcessEnv | Record; +/** Re-export of {@link SessionRuntimeHints} under the Metro-specific alias used by public API consumers. */ export type MetroRuntimeHints = SessionRuntimeHints; export type MetroBridgeResult = { diff --git a/src/platforms/android/perf.ts b/src/platforms/android/perf.ts index b49692765..5705be2ae 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -2,6 +2,7 @@ import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { adbArgs } from './adb.ts'; +import { roundPercent } from '../perf-utils.ts'; export const ANDROID_CPU_SAMPLE_METHOD = 'adb-shell-dumpsys-cpuinfo'; export const ANDROID_CPU_SAMPLE_DESCRIPTION = @@ -166,10 +167,6 @@ function matchesAndroidPackageProcess(processName: string, packageName: string): return processName === packageName || processName.startsWith(`${packageName}:`); } -function roundPercent(value: number): number { - return Math.round(value * 10) / 10; -} - function matchLabeledNumber(text: string, label: string): number | undefined { const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = text.match(new RegExp(`${escapedLabel}:\\s*([0-9][0-9,]*)`, 'i')); diff --git a/src/platforms/ios/perf.ts b/src/platforms/ios/perf.ts index 0d35eff68..cda6fc236 100644 --- a/src/platforms/ios/perf.ts +++ b/src/platforms/ios/perf.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; import { runCmd } from '../../utils/exec.ts'; +import { roundPercent } from '../perf-utils.ts'; import { uniqueStrings } from '../../daemon/action-utils.ts'; import { IOS_DEVICECTL_DEFAULT_HINT, @@ -694,10 +695,6 @@ function resolveIosDevicePerfHint(stdout: string, stderr: string): string { return 'Ensure the iOS device is unlocked, trusted, visible to xctrace, and the target app stays active while perf samples it.'; } -function roundPercent(value: number): number { - return Math.round(value * 10) / 10; -} - function maxNullableNumber(left: number | null, right: number | null): number | null { if (left === null) return right; if (right === null) return left; diff --git a/src/platforms/linux/clipboard.ts b/src/platforms/linux/clipboard.ts index 385ff5e8b..19ea96a16 100644 --- a/src/platforms/linux/clipboard.ts +++ b/src/platforms/linux/clipboard.ts @@ -1,50 +1,25 @@ -import { runCmd, whichCmd } from '../../utils/exec.ts'; -import { AppError } from '../../utils/errors.ts'; -import { isWayland } from './linux-env.ts'; +import { runCmd } from '../../utils/exec.ts'; +import { createLinuxToolResolver } from './tool-resolver.ts'; type ClipboardTool = 'wl-clipboard' | 'xclip' | 'xsel'; -let cachedTool: { tool: ClipboardTool; display: 'wayland' | 'x11' } | null = null; - -async function resolveClipboardTool(): Promise<{ - tool: ClipboardTool; - display: 'wayland' | 'x11'; -}> { - if (cachedTool) return cachedTool; - - if (isWayland()) { - // wl-clipboard provides both wl-paste and wl-copy - if (await whichCmd('wl-paste')) { - cachedTool = { tool: 'wl-clipboard', display: 'wayland' }; - return cachedTool; - } - throw new AppError( - 'TOOL_MISSING', - 'wl-paste (wl-clipboard) is required for clipboard access on Wayland. Install via your package manager.', - ); - } - - if (await whichCmd('xclip')) { - cachedTool = { tool: 'xclip', display: 'x11' }; - return cachedTool; - } - if (await whichCmd('xsel')) { - cachedTool = { tool: 'xsel', display: 'x11' }; - return cachedTool; - } - throw new AppError( - 'TOOL_MISSING', +const clipboardResolver = createLinuxToolResolver({ + wayland: [{ tool: 'wl-clipboard', command: 'wl-paste' }], + x11: [ + { tool: 'xclip', command: 'xclip' }, + { tool: 'xsel', command: 'xsel' }, + ], + waylandError: + 'wl-paste (wl-clipboard) is required for clipboard access on Wayland. Install via your package manager.', + x11Error: 'xclip or xsel is required for clipboard access on X11. Install via your package manager.', - ); -} +}); /** Reset cached tool (for testing). */ -export function resetClipboardToolCache(): void { - cachedTool = null; -} +export const resetClipboardToolCache = clipboardResolver.resetCache; export async function readLinuxClipboard(): Promise { - const { tool } = await resolveClipboardTool(); + const { tool } = await clipboardResolver.resolve(); switch (tool) { case 'wl-clipboard': { @@ -72,7 +47,7 @@ export async function readLinuxClipboard(): Promise { } export async function writeLinuxClipboard(text: string): Promise { - const { tool } = await resolveClipboardTool(); + const { tool } = await clipboardResolver.resolve(); switch (tool) { case 'wl-clipboard': diff --git a/src/platforms/linux/screenshot.ts b/src/platforms/linux/screenshot.ts index 1d5985999..0d6741605 100644 --- a/src/platforms/linux/screenshot.ts +++ b/src/platforms/linux/screenshot.ts @@ -1,54 +1,26 @@ -import { runCmd, whichCmd } from '../../utils/exec.ts'; -import { AppError } from '../../utils/errors.ts'; -import { isWayland } from './linux-env.ts'; +import { runCmd } from '../../utils/exec.ts'; +import { createLinuxToolResolver } from './tool-resolver.ts'; type ScreenshotTool = 'grim' | 'gnome-screenshot' | 'scrot' | 'import'; -let cachedTool: { tool: ScreenshotTool; display: 'wayland' | 'x11' } | null = null; - -async function resolveScreenshotTool(): Promise<{ - tool: ScreenshotTool; - display: 'wayland' | 'x11'; -}> { - if (cachedTool) return cachedTool; - - if (isWayland()) { - if (await whichCmd('grim')) { - cachedTool = { tool: 'grim', display: 'wayland' }; - return cachedTool; - } - if (await whichCmd('gnome-screenshot')) { - cachedTool = { tool: 'gnome-screenshot', display: 'wayland' }; - return cachedTool; - } - throw new AppError( - 'TOOL_MISSING', - 'grim or gnome-screenshot is required for screenshots on Wayland. Install via your package manager.', - ); - } - - if (await whichCmd('scrot')) { - cachedTool = { tool: 'scrot', display: 'x11' }; - return cachedTool; - } - if (await whichCmd('import')) { - cachedTool = { tool: 'import', display: 'x11' }; - return cachedTool; - } - if (await whichCmd('gnome-screenshot')) { - cachedTool = { tool: 'gnome-screenshot', display: 'x11' }; - return cachedTool; - } - throw new AppError( - 'TOOL_MISSING', +const screenshotResolver = createLinuxToolResolver({ + wayland: [ + { tool: 'grim', command: 'grim' }, + { tool: 'gnome-screenshot', command: 'gnome-screenshot' }, + ], + x11: [ + { tool: 'scrot', command: 'scrot' }, + { tool: 'import', command: 'import' }, + { tool: 'gnome-screenshot', command: 'gnome-screenshot' }, + ], + waylandError: + 'grim or gnome-screenshot is required for screenshots on Wayland. Install via your package manager.', + x11Error: 'scrot, import (ImageMagick), or gnome-screenshot is required for screenshots on X11. Install via your package manager.', - ); -} +}); /** Reset cached tool (for testing). */ -export function resetScreenshotToolCache(): void { - cachedTool = null; -} +export const resetScreenshotToolCache = screenshotResolver.resetCache; /** * Capture a screenshot of the Linux desktop. @@ -58,7 +30,7 @@ export function resetScreenshotToolCache(): void { * - `scrot` or `import` (ImageMagick) on X11 */ export async function screenshotLinux(outPath: string): Promise { - const { tool } = await resolveScreenshotTool(); + const { tool } = await screenshotResolver.resolve(); switch (tool) { case 'grim': diff --git a/src/platforms/linux/tool-resolver.ts b/src/platforms/linux/tool-resolver.ts new file mode 100644 index 000000000..34f2713c4 --- /dev/null +++ b/src/platforms/linux/tool-resolver.ts @@ -0,0 +1,52 @@ +import { whichCmd } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; +import { isWayland, type DisplayServer } from './linux-env.ts'; + +type ToolCandidate = { + tool: T; + command: string; +}; + +type ResolvedTool = { + tool: T; + display: DisplayServer; +}; + +/** + * Resolve a Linux tool by probing candidates in order. + * Caches the result so subsequent calls skip detection. + */ +export function createLinuxToolResolver(config: { + wayland: ToolCandidate[]; + x11: ToolCandidate[]; + waylandError: string; + x11Error: string; +}): { + resolve: () => Promise>; + resetCache: () => void; +} { + let cached: ResolvedTool | null = null; + + async function resolve(): Promise> { + if (cached) return cached; + const display: DisplayServer = isWayland() ? 'wayland' : 'x11'; + const candidates = display === 'wayland' ? config.wayland : config.x11; + for (const candidate of candidates) { + if (await whichCmd(candidate.command)) { + cached = { tool: candidate.tool, display }; + return cached; + } + } + throw new AppError( + 'TOOL_MISSING', + display === 'wayland' ? config.waylandError : config.x11Error, + ); + } + + return { + resolve, + resetCache: () => { + cached = null; + }, + }; +} diff --git a/src/platforms/perf-utils.ts b/src/platforms/perf-utils.ts new file mode 100644 index 000000000..cc2e4ed6c --- /dev/null +++ b/src/platforms/perf-utils.ts @@ -0,0 +1,3 @@ +export function roundPercent(value: number): number { + return Math.round(value * 10) / 10; +} diff --git a/src/utils/__tests__/cli-option-schema.test.ts b/src/utils/__tests__/cli-option-schema.test.ts index 4e8958ca1..702a2cbdd 100644 --- a/src/utils/__tests__/cli-option-schema.test.ts +++ b/src/utils/__tests__/cli-option-schema.test.ts @@ -54,7 +54,10 @@ test('remote config schema stays aligned with CLI option metadata', () => { assert.equal(definition.type, field.type); assert.equal(definition.min, 'min' in field ? field.min : undefined); assert.equal(definition.max, 'max' in field ? field.max : undefined); - assert.deepEqual(definition.enumValues ?? [], 'enumValues' in field ? field.enumValues ?? [] : []); + assert.deepEqual( + definition.enumValues ?? [], + 'enumValues' in field ? (field.enumValues ?? []) : [], + ); } }); diff --git a/src/utils/finders.ts b/src/utils/finders.ts index a06c712e3..fbae25dca 100644 --- a/src/utils/finders.ts +++ b/src/utils/finders.ts @@ -85,7 +85,7 @@ function matchRole(value: string | undefined, query: string): number { return 0; } -function normalizeText(value: string): string { +export function normalizeText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, ' '); } diff --git a/src/utils/mobile-snapshot-semantics.ts b/src/utils/mobile-snapshot-semantics.ts index 1e43f6da8..1fa852e71 100644 --- a/src/utils/mobile-snapshot-semantics.ts +++ b/src/utils/mobile-snapshot-semantics.ts @@ -1,7 +1,7 @@ import { isRectVisibleInViewport, resolveViewportRect } from './rect-visibility.ts'; import { inferVerticalScrollIndicatorDirections } from './scroll-indicator.ts'; import type { Rect, SnapshotNode } from './snapshot.ts'; -import { buildSnapshotNodeMap } from './snapshot-tree.ts'; +import { buildSnapshotNodeMap, displayNodeLabel } from './snapshot-tree.ts'; import { isScrollableNodeLike } from './scrollable.ts'; type Direction = 'above' | 'below'; @@ -207,10 +207,6 @@ function isDiscoverableOffscreenNode(node: SnapshotNode): boolean { ); } -function displayNodeLabel(node: SnapshotNode): string { - return node.label?.trim() || node.value?.trim() || node.identifier?.trim() || ''; -} - function uniqueLabels(nodes: SnapshotNode[]): string[] { const seen = new Set(); const labels: string[] = []; diff --git a/src/utils/output.ts b/src/utils/output.ts index 28093b4cc..da2f339c8 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { AppError, normalizeError, type NormalizedError } from './errors.ts'; import { buildSnapshotDisplayLines, formatSnapshotLine } from './snapshot-lines.ts'; import type { SnapshotNode, SnapshotVisibility } from './snapshot.ts'; +import { displayNodeLabel } from './snapshot-tree.ts'; import type { ScreenshotDiffResult } from './screenshot-diff.ts'; import { styleText } from 'node:util'; import { buildMobileSnapshotPresentation } from './mobile-snapshot-semantics.ts'; @@ -327,10 +328,6 @@ function detectPossibleRepeatedNavSubtree(nodes: SnapshotNode[]): boolean { return duplicateCount >= 8; } -function displayNodeLabel(node: SnapshotNode): string { - return node.label?.trim() || node.value?.trim() || node.identifier?.trim() || ''; -} - function renderSnapshotDisplayLines(lines: ReturnType): string[] { return lines.flatMap((line) => [line.text, ...readHiddenContentHintLines(line)]); } diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 44628d28b..1ef08c95b 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -43,7 +43,7 @@ type RetryTelemetryEvent = { const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS); export function isEnvTruthy(value: string | undefined): boolean { - return ['1', 'true', 'yes', 'on'].includes((value ?? '').toLowerCase()); + return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase()); } export const TIMEOUT_PROFILES: Record = { diff --git a/src/utils/session-binding.ts b/src/utils/session-binding.ts index 48311b4f1..7a2723187 100644 --- a/src/utils/session-binding.ts +++ b/src/utils/session-binding.ts @@ -1,6 +1,7 @@ import { AppError } from './errors.ts'; import type { CliFlags } from './command-schema.ts'; import type { DaemonLockPolicy } from '../daemon/types.ts'; +import { isEnvTruthy } from './retry.ts'; export type BindingSettings = { defaultPlatform?: CliFlags['platform']; @@ -107,19 +108,6 @@ function readConflictMode(raw: string | undefined): DaemonLockPolicy | undefined throw new AppError('INVALID_ARGS', `Invalid session lock mode: ${raw}. Use reject or strip.`); } -function isEnvTruthy(raw: string | undefined): boolean { - if (!raw) return false; - switch (raw.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - default: - return false; - } -} - function hasConfiguredSession(raw: string | undefined): boolean { return typeof raw === 'string' && raw.trim().length > 0; } diff --git a/src/utils/snapshot-tree.ts b/src/utils/snapshot-tree.ts index cc671757f..afec580e8 100644 --- a/src/utils/snapshot-tree.ts +++ b/src/utils/snapshot-tree.ts @@ -1,4 +1,4 @@ -import type { RawSnapshotNode } from './snapshot.ts'; +import type { RawSnapshotNode, SnapshotNode } from './snapshot.ts'; export function normalizeSnapshotTree(nodes: RawSnapshotNode[]): RawSnapshotNode[] { const originalToNormalizedIndex = new Map(); @@ -39,3 +39,7 @@ export function normalizeSnapshotTree(nodes: RawSnapshotNode[]): RawSnapshotNode export function buildSnapshotNodeMap(nodes: T[]): Map { return new Map(nodes.map((node) => [node.index, node])); } + +export function displayNodeLabel(node: SnapshotNode): string { + return node.label?.trim() || node.value?.trim() || node.identifier?.trim() || ''; +}