diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index e6e6f0a15..6682b5e27 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -104,6 +104,8 @@ export function normalizeError( }; } +const GENERIC_EXIT_MESSAGE = /^\S+ exited with code -?\d+$/; + function maybeEnrichCommandFailedMessage( code: string, message: string, @@ -114,7 +116,12 @@ function maybeEnrichCommandFailedMessage( const stderr = typeof details?.stderr === 'string' ? details.stderr : ''; const excerpt = firstStderrLine(stderr); if (!excerpt) return message; - return excerpt; + // Generic " exited with code N" wraps carry no context of their own, + // so the stderr excerpt replaces them outright. Curated wrap messages keep + // the specific failure description and gain the excerpt as a suffix. + if (GENERIC_EXIT_MESSAGE.test(message)) return excerpt; + if (message.includes(excerpt)) return message; + return `${message}: ${excerpt}`; } function firstStderrLine(stderr: string): string | null { diff --git a/src/platforms/android/app-helpers.ts b/src/platforms/android/app-helpers.ts index 73176a3ee..cb34ab5e4 100644 --- a/src/platforms/android/app-helpers.ts +++ b/src/platforms/android/app-helpers.ts @@ -1,4 +1,5 @@ import { AppError } from '../../kernel/errors.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import { resolveAppsFilter, type AppsFilter } from '../../contracts/app-inventory.ts'; import type { AndroidAdbExecutor } from './adb-executor.ts'; import { @@ -101,11 +102,11 @@ function parseAndroidLaunchablePackageOutput(stdout: string): string[] { async function listAndroidUserInstalledPackagesWithAdb(adb: AndroidAdbExecutor): Promise { const result = await adb(['shell', 'pm', 'list', 'packages', '-3'], { allowFailure: true }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to list Android user-installed apps', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to list Android user-installed apps', + execFailureDetails(result), + ); } return parseAndroidUserInstalledPackages(result.stdout); } diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index b4310a97c..9645d2adc 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { resolveFileOverridePath, runCmd, whichCmd } from '../../utils/exec.ts'; +import { execFailureDetails, resolveFileOverridePath, runCmd, whichCmd } from '../../utils/exec.ts'; import { AppError } from '../../kernel/errors.ts'; import { sleep } from '../../utils/timeouts.ts'; import type { AppsFilter } from '../../contracts/app-inventory.ts'; @@ -726,11 +726,11 @@ async function uninstallAndroidApp(device: DeviceInfo, app: string): Promise<{ p if (result.exitCode !== 0) { const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (!output.includes('unknown package') && !output.includes('not installed')) { - throw new AppError('COMMAND_FAILED', `adb uninstall failed for ${resolved.value}`, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + `adb uninstall failed for ${resolved.value}`, + execFailureDetails(result), + ); } } return { package: resolved.value }; diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts index 62113e504..1cd837577 100644 --- a/src/platforms/android/device-input-state.ts +++ b/src/platforms/android/device-input-state.ts @@ -1,4 +1,5 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; import { isClipboardShellUnsupported, sleep } from './adb.ts'; @@ -84,11 +85,11 @@ export async function getAndroidKeyboardStatusWithAdb( allowFailure: true, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to query Android keyboard state', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to query Android keyboard state', + execFailureDetails(result), + ); } return parseAndroidKeyboardState(result.stdout); } @@ -326,11 +327,11 @@ async function runAndroidClipboardShellCommand( ); } if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', `Failed to ${operation} Android clipboard text`, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to ${operation} Android clipboard text`, + execFailureDetails(result), + ); } return result.stdout; } diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index cbe7ce46b..f7ddb6cf2 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -1,4 +1,4 @@ -import { runCmd, runCmdDetached, whichCmd } from '../../utils/exec.ts'; +import { execFailureDetails, runCmd, runCmdDetached, whichCmd } from '../../utils/exec.ts'; import type { ExecResult } from '../../utils/exec.ts'; import { sleep } from '../../utils/timeouts.ts'; import { AppError, asAppError } from '../../kernel/errors.ts'; @@ -311,12 +311,13 @@ async function listAndroidAvdNames(): Promise { timeoutMs: ANDROID_BOOT_PROP_TIMEOUT_MS, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to list Android emulator AVDs', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - hint: 'Verify Android emulator tooling is installed and available in PATH.', - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to list Android emulator AVDs', + execFailureDetails(result, { + hint: 'Verify Android emulator tooling is installed and available in PATH.', + }), + ); } return parseAndroidAvdList(result.stdout); } diff --git a/src/platforms/android/logcat.ts b/src/platforms/android/logcat.ts index 839cbae82..25de20aeb 100644 --- a/src/platforms/android/logcat.ts +++ b/src/platforms/android/logcat.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { AppError } from '../../kernel/errors.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import type { AndroidAdbExecutor, AndroidAdbProcess, AndroidAdbProvider } from './adb-executor.ts'; export type AndroidLogcatCaptureOptions = { @@ -28,11 +29,11 @@ export async function captureAndroidLogcatWithAdb( signal: options.signal, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to capture Android logcat', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to capture Android logcat', + execFailureDetails(result), + ); } return result.stdout; } diff --git a/src/platforms/android/multitouch-helper.ts b/src/platforms/android/multitouch-helper.ts index 3e49ce5da..76370bad0 100644 --- a/src/platforms/android/multitouch-helper.ts +++ b/src/platforms/android/multitouch-helper.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { AppError, normalizeError } from '../../kernel/errors.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { findProjectRoot, readVersion } from '../../utils/version.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; @@ -396,12 +397,11 @@ export async function runAndroidMultiTouchHelperGesture(options: { ); } if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Android multi-touch helper failed', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - helper: output, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Android multi-touch helper failed', + execFailureDetails(result, { helper: output }), + ); } return output; } @@ -528,13 +528,11 @@ export async function ensureAndroidMultiTouchHelper(options: { timeoutMs: ANDROID_MULTITOUCH_HELPER_INSTALL_TIMEOUT_MS, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to install Android multi-touch helper', { - packageName, - versionCode, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to install Android multi-touch helper', + execFailureDetails(result, { packageName, versionCode }), + ); } installedMultiTouchHelpers.add(cacheKey); return { diff --git a/src/platforms/android/perf.ts b/src/platforms/android/perf.ts index 96e332c6d..5e9b5e21b 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import { splitNonEmptyTrimmedLines } from '../../utils/parsing.ts'; import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; import { parseNumericToken } from './perf-parsing.ts'; @@ -133,16 +134,13 @@ export async function captureAndroidHeapSnapshot( throw new AppError( 'COMMAND_FAILED', `Failed to capture Android heap dump for ${packageName}`, - { + execFailureDetails(dumpResult, { kind: 'android-hprof', package: packageName, pid, remotePath, - exitCode: dumpResult.exitCode, - stdout: dumpResult.stdout, - stderr: dumpResult.stderr, hint: resolveAndroidHeapDumpHint(dumpResult.stdout, dumpResult.stderr), - }, + }), ); } @@ -152,17 +150,18 @@ export async function captureAndroidHeapSnapshot( }); if (pullResult.exitCode !== 0) { await cleanupLocalArtifact(outPath, hadLocalArtifact); - throw new AppError('COMMAND_FAILED', `Failed to pull Android heap dump for ${packageName}`, { - kind: 'android-hprof', - package: packageName, - pid, - remotePath, - path: outPath, - exitCode: pullResult.exitCode, - stdout: pullResult.stdout, - stderr: pullResult.stderr, - hint: 'Verify the daemon can write the requested --out path and retry. The heap dump stays on-device only until cleanup runs.', - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to pull Android heap dump for ${packageName}`, + execFailureDetails(pullResult, { + kind: 'android-hprof', + package: packageName, + pid, + remotePath, + path: outPath, + hint: 'Verify the daemon can write the requested --out path and retry. The heap dump stays on-device only until cleanup runs.', + }), + ); } const stat = await fs.stat(outPath).catch(() => null); diff --git a/src/platforms/android/settings.ts b/src/platforms/android/settings.ts index f18792747..5c3f0f51a 100644 --- a/src/platforms/android/settings.ts +++ b/src/platforms/android/settings.ts @@ -1,4 +1,5 @@ import { AppError } from '../../kernel/errors.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import { requireLocationCoordinates } from '../../utils/location-coordinates.ts'; import { @@ -252,11 +253,11 @@ async function resolveAndroidAppearanceTarget( allowFailure: true, }); if (currentResult.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to read current Android appearance', { - stdout: currentResult.stdout, - stderr: currentResult.stderr, - exitCode: currentResult.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read current Android appearance', + execFailureDetails(currentResult), + ); } const current = parseAndroidAppearance(currentResult.stdout, currentResult.stderr); if (!current) { diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index 793f84f99..5ea6ec9d2 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -1,4 +1,5 @@ import { AppError } from '../../kernel/errors.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import type { SnapshotOptions } from '../../kernel/snapshot.ts'; import { parseInstrumentationRecords, @@ -58,12 +59,11 @@ export async function captureAndroidSnapshotWithHelper( await removeHelperOutputFile(options.adb, resolved.outputPath); } if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - helper: output.metadata, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper failed', + execFailureDetails(result, { helper: output.metadata }), + ); } return output; } diff --git a/src/platforms/android/snapshot-helper-install.ts b/src/platforms/android/snapshot-helper-install.ts index 776ca39e7..1c78c5aa0 100644 --- a/src/platforms/android/snapshot-helper-install.ts +++ b/src/platforms/android/snapshot-helper-install.ts @@ -1,4 +1,5 @@ import { AppError } from '../../kernel/errors.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import { readAndroidSnapshotHelperInstallOptions, verifyAndroidSnapshotHelperArtifact, @@ -117,13 +118,11 @@ export async function ensureAndroidSnapshotHelper(options: { ); if (result.exitCode !== 0) { forgetInstalledSnapshotHelper(installCacheKey); - throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { - packageName, - versionCode, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to install Android snapshot helper', + execFailureDetails(result, { packageName, versionCode }), + ); } rememberInstalledSnapshotHelper(installCacheKey, versionCode); diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 6100d6fc2..528117fe5 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { withRetry } from '../../utils/retry.ts'; +import { execFailureDetails } from '../../utils/exec.ts'; import { AppError, normalizeError, toAppErrorCode } from '../../kernel/errors.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; @@ -682,12 +683,11 @@ async function dumpUiHierarchyOnce(adb: AndroidAdbExecutor): Promise { }); const reportedPath = readDumpPath(dumpResult.stdout, dumpResult.stderr); if (dumpResult.exitCode !== 0 && !reportedPath) { - throw new AppError('COMMAND_FAILED', 'uiautomator dump did not return XML', { - stdout: dumpResult.stdout, - stderr: dumpResult.stderr, - exitCode: dumpResult.exitCode, - reason: 'missing_fresh_dump', - }); + throw new AppError( + 'COMMAND_FAILED', + 'uiautomator dump did not return XML', + execFailureDetails(dumpResult, { reason: 'missing_fresh_dump' }), + ); } const actualPath = reportedPath ?? dumpPath; diff --git a/src/platforms/apple/core/app-device-io.ts b/src/platforms/apple/core/app-device-io.ts index 6c37053e3..4c47c2028 100644 --- a/src/platforms/apple/core/app-device-io.ts +++ b/src/platforms/apple/core/app-device-io.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../utils/exec.ts'; import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts'; import { runSimctl } from './apps-simctl.ts'; @@ -15,11 +16,11 @@ export async function readIosClipboardText(device: DeviceInfo): Promise await ensureBootedSimulator(device); const result = await runSimctl(device, ['pbpaste', device.id], { allowFailure: true }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to read iOS simulator clipboard', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read iOS simulator clipboard', + execFailureDetails(result), + ); } return result.stdout.replace(/\r\n/g, '\n').replace(/\n$/, ''); } @@ -36,11 +37,11 @@ export async function writeIosClipboardText(device: DeviceInfo, text: string): P stdin: text, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to write iOS simulator clipboard', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to write iOS simulator clipboard', + execFailureDetails(result), + ); } } diff --git a/src/platforms/apple/core/app-install.ts b/src/platforms/apple/core/app-install.ts index 4226f8701..701c70339 100644 --- a/src/platforms/apple/core/app-install.ts +++ b/src/platforms/apple/core/app-install.ts @@ -1,5 +1,6 @@ import type { DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../utils/exec.ts'; import { IOS_DEVICE_INSTALL_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; import { runIosDevicectl } from './devicectl.ts'; import { prepareIosInstallArtifact } from './install-artifact.ts'; @@ -30,15 +31,18 @@ async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundl const stderr = String(result.stderr ?? ''); const output = `${stdout}\n${stderr}`.toLowerCase(); if (!isMissingAppErrorOutput(output)) { - throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, { - cmd: 'xcrun', - args, - exitCode: result.exitCode, - stdout, - stderr, - deviceId: device.id, - hint: maybeResolveIosDevicectlHint(stdout, stderr), - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to uninstall iOS app ${bundleId}`, + execFailureDetails(result, { + cmd: 'xcrun', + args, + stdout, + stderr, + deviceId: device.id, + hint: maybeResolveIosDevicectlHint(stdout, stderr), + }), + ); } } return { bundleId }; @@ -52,11 +56,11 @@ async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundl if (result.exitCode !== 0) { const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (!isMissingAppErrorOutput(output)) { - throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + `simctl uninstall failed for ${bundleId}`, + execFailureDetails(result), + ); } } diff --git a/src/platforms/apple/core/app-launch.ts b/src/platforms/apple/core/app-launch.ts index 72e4ffc04..69d523a75 100644 --- a/src/platforms/apple/core/app-launch.ts +++ b/src/platforms/apple/core/app-launch.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; import { emitDiagnostic } from '../../../utils/diagnostics.ts'; +import { execFailureDetails } from '../../../utils/exec.ts'; import { LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE, LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE, @@ -160,13 +161,11 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise { diff --git a/src/platforms/apple/core/devicectl.ts b/src/platforms/apple/core/devicectl.ts index b4f3e6a8a..20611427e 100644 --- a/src/platforms/apple/core/devicectl.ts +++ b/src/platforms/apple/core/devicectl.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import type { DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../utils/exec.ts'; import { IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; import { runXcrun } from './tool-provider.ts'; @@ -47,15 +48,18 @@ export async function runIosDevicectl( if (result.exitCode === 0) return; const stdout = String(result.stdout ?? ''); const stderr = String(result.stderr ?? ''); - throw new AppError('COMMAND_FAILED', `Failed to ${context.action}`, { - cmd: 'xcrun', - args: fullArgs, - exitCode: result.exitCode, - stdout, - stderr, - deviceId: context.deviceId, - hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to ${context.action}`, + execFailureDetails(result, { + cmd: 'xcrun', + args: fullArgs, + stdout, + stderr, + deviceId: context.deviceId, + hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + }), + ); } export async function listIosDeviceApps( @@ -105,15 +109,18 @@ async function runIosDevicectlJsonCommand( if (result.exitCode !== 0) { const stdout = String(result.stdout ?? ''); const stderr = String(result.stderr ?? ''); - throw new AppError('COMMAND_FAILED', options.failureMessage, { - cmd: 'xcrun', - args, - exitCode: result.exitCode, - stdout, - stderr, - deviceId: device.id, - hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, - }); + throw new AppError( + 'COMMAND_FAILED', + options.failureMessage, + execFailureDetails(result, { + cmd: 'xcrun', + args, + stdout, + stderr, + deviceId: device.id, + hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + }), + ); } return JSON.parse(await fs.readFile(jsonPath, 'utf8')); } catch (error) { diff --git a/src/platforms/apple/core/perf-xctrace.ts b/src/platforms/apple/core/perf-xctrace.ts index ab9955658..d231508ad 100644 --- a/src/platforms/apple/core/perf-xctrace.ts +++ b/src/platforms/apple/core/perf-xctrace.ts @@ -11,6 +11,7 @@ import { } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; import { + execFailureDetails, runCmdBackground, type ExecBackgroundResult, type ExecResult, @@ -128,14 +129,15 @@ export async function stopAppleXctracePerfCapture( } const result = await stopAppleXctraceProcess(capture, { failOnForcedKill: true }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', `Failed to stop Apple xctrace ${capture.mode} capture`, { - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, - tracePath: capture.outPath, - captureCleanedUp: true, - hint: resolveIosDevicePerfHint(result.stdout, result.stderr), - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to stop Apple xctrace ${capture.mode} capture`, + execFailureDetails(result, { + tracePath: capture.outPath, + captureCleanedUp: true, + hint: resolveIosDevicePerfHint(result.stdout, result.stderr), + }), + ); } if (outPath !== capture.outPath) { await fs.rename(capture.outPath, outPath).catch(async () => { @@ -195,15 +197,16 @@ export async function writeAppleXctracePerfReport(params: { timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS, }); if (exportResult.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to export Apple xctrace report metadata', { - cmd: 'xcrun', - args: exportArgs, - exitCode: exportResult.exitCode, - stdout: exportResult.stdout, - stderr: exportResult.stderr, - tracePath: params.tracePath, - hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to export Apple xctrace report metadata', + execFailureDetails(exportResult, { + cmd: 'xcrun', + args: exportArgs, + tracePath: params.tracePath, + hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), + }), + ); } const report = buildAppleXctracePerfReport({ ...params, @@ -292,16 +295,17 @@ async function startAppleXctraceRecordWithRetry( } const failure = lastImmediateFailure ?? { stdout: '', stderr: '', exitCode: 1 }; - throw new AppError('COMMAND_FAILED', context.failureMessage, { - cmd: 'xcrun', - args, - exitCode: failure.exitCode, - stdout: failure.stdout, - stderr: failure.stderr, - appBundleId: context.appBundleId, - deviceId: context.device.id, - hint: resolveIosDevicePerfHint(failure.stdout, failure.stderr), - }); + throw new AppError( + 'COMMAND_FAILED', + context.failureMessage, + execFailureDetails(failure, { + cmd: 'xcrun', + args, + appBundleId: context.appBundleId, + deviceId: context.device.id, + hint: resolveIosDevicePerfHint(failure.stdout, failure.stderr), + }), + ); } async function waitForImmediateAppleXctraceExit( diff --git a/src/platforms/apple/core/perf.ts b/src/platforms/apple/core/perf.ts index e4234695a..0ab8ff157 100644 --- a/src/platforms/apple/core/perf.ts +++ b/src/platforms/apple/core/perf.ts @@ -10,7 +10,7 @@ import { type PublicPlatform, } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; -import type { ExecResult } from '../../../utils/exec.ts'; +import { execFailureDetails, type ExecResult } from '../../../utils/exec.ts'; import { splitNonEmptyTrimmedLines } from '../../../utils/parsing.ts'; import { roundPercent } from '../../perf-utils.ts'; import { uniqueStrings } from '../../../daemon/action-utils.ts'; @@ -188,17 +188,18 @@ export async function captureAppleMemorySnapshot( if (result.exitCode !== 0) { await cleanupLocalArtifact(outPath, hadLocalArtifact); // fallow-ignore-next-line code-duplication - throw new AppError('COMMAND_FAILED', `Failed to capture Apple memgraph for ${appBundleId}`, { - kind: 'memgraph', - appBundleId, - pid: process.pid, - processName: path.basename(readProcessCommandToken(process.command)), - path: outPath, - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, - hint: resolveAppleMemorySnapshotHint(device, result.stdout, result.stderr), - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to capture Apple memgraph for ${appBundleId}`, + execFailureDetails(result, { + kind: 'memgraph', + appBundleId, + pid: process.pid, + processName: path.basename(readProcessCommandToken(process.command)), + path: outPath, + hint: resolveAppleMemorySnapshotHint(device, result.stdout, result.stderr), + }), + ); } const stat = await fs.stat(outPath).catch(() => null); @@ -536,16 +537,17 @@ async function recordIosDeviceTrace(params: { capturedAtMs: record.capturedAtMs, }; } - throw new AppError('COMMAND_FAILED', params.failureMessage, { - cmd: 'xcrun', - args: recordArgs, - exitCode: record.result.exitCode, - stdout: record.result.stdout, - stderr: record.result.stderr, - appBundleId, - deviceId: device.id, - hint: resolveIosDevicePerfHint(record.result.stdout, record.result.stderr), - }); + throw new AppError( + 'COMMAND_FAILED', + params.failureMessage, + execFailureDetails(record.result, { + cmd: 'xcrun', + args: recordArgs, + appBundleId, + deviceId: device.id, + hint: resolveIosDevicePerfHint(record.result.stdout, record.result.stderr), + }), + ); } async function runIosDeviceTraceRecord( @@ -643,16 +645,17 @@ async function exportIosDevicePerfTable( timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS, }); if (exportResult.exitCode === 0) return; - throw new AppError('COMMAND_FAILED', `Failed to export iOS device ${schema} data`, { - cmd: 'xcrun', - args: exportArgs, - exitCode: exportResult.exitCode, - stdout: exportResult.stdout, - stderr: exportResult.stderr, - appBundleId, - deviceId: device.id, - hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to export iOS device ${schema} data`, + execFailureDetails(exportResult, { + cmd: 'xcrun', + args: exportArgs, + appBundleId, + deviceId: device.id, + hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr), + }), + ); } async function exportOptionalIosDevicePerfTable( @@ -978,12 +981,11 @@ async function resolveMacOsBundlePath(appBundleId: string): Promise { timeoutMs: APPLE_PERF_TIMEOUT_MS, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', `Failed to resolve macOS app bundle for ${appBundleId}`, { - appBundleId, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + `Failed to resolve macOS app bundle for ${appBundleId}`, + execFailureDetails(result, { appBundleId }), + ); } const bundlePath = result.stdout @@ -1016,13 +1018,10 @@ async function resolveIosSimulatorAppContainer( throw new AppError( 'COMMAND_FAILED', `Failed to resolve iOS simulator app container for ${appBundleId}`, - { + execFailureDetails(result, { appBundleId, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, hint: 'Ensure the iOS simulator app is installed and booted, then retry perf.', - }, + }), ); } const appPath = result.stdout.trim(); diff --git a/src/platforms/apple/core/runner/runner-artifact-env.ts b/src/platforms/apple/core/runner/runner-artifact-env.ts index a5fbbeaf8..9667c8dab 100644 --- a/src/platforms/apple/core/runner/runner-artifact-env.ts +++ b/src/platforms/apple/core/runner/runner-artifact-env.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '../../../../kernel/errors.ts'; import type { DefinedEnvMap as EnvMap } from '../../../../utils/env-map.ts'; +import { execFailureDetails } from '../../../../utils/exec.ts'; import { runAppleToolCommand } from '../tool-provider.ts'; const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { @@ -99,10 +100,11 @@ async function writeXctestrunPlist( }, ); if (plistResult.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to write xctestrun plist', { - tmpXctestrunPath, - stderr: plistResult.stderr, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to write xctestrun plist', + execFailureDetails(plistResult, { tmpXctestrunPath }), + ); } } diff --git a/src/platforms/apple/core/runner/runner-icon.ts b/src/platforms/apple/core/runner/runner-icon.ts index 5307b096f..5a25d238c 100644 --- a/src/platforms/apple/core/runner/runner-icon.ts +++ b/src/platforms/apple/core/runner/runner-icon.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '../../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../../utils/exec.ts'; import { readApplePlistJson, runAppleToolCommand } from '../tool-provider.ts'; const ICON_PLIST_KEYS = ['CFBundleIcons', 'CFBundleIcons~ipad'] as const; @@ -106,11 +107,11 @@ async function writeIconPlistValue( { allowFailure: true }, ); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to update XCTest runner icon plist', { - key, - plistPath, - stderr: result.stderr, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to update XCTest runner icon plist', + execFailureDetails(result, { key, plistPath }), + ); } } @@ -121,10 +122,11 @@ async function codesignRunnerApp(runnerAppPath: string): Promise { { allowFailure: true }, ); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to sign XCTest runner app after icon update', { - runnerAppPath, - stderr: result.stderr, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to sign XCTest runner app after icon update', + execFailureDetails(result, { runnerAppPath }), + ); } } diff --git a/src/platforms/apple/core/runner/runner-transport.ts b/src/platforms/apple/core/runner/runner-transport.ts index 174aa625d..cf0f98a2e 100644 --- a/src/platforms/apple/core/runner/runner-transport.ts +++ b/src/platforms/apple/core/runner/runner-transport.ts @@ -7,6 +7,7 @@ import { isRequestCanceledError, } from '../../../../daemon/request-cancel.ts'; import { AppError } from '../../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../../utils/exec.ts'; import { Deadline, retryWithPolicy } from '../../../../utils/retry.ts'; import type { DeviceInfo } from '../../../../kernel/device.ts'; import { classifyBootFailure, bootFailureHint } from '../../../boot-diagnostics.ts'; @@ -542,14 +543,15 @@ async function postCommandViaSimulator( stderr: result.stderr, context: { platform: 'ios', phase: 'connect' }, }); - throw new AppError('COMMAND_FAILED', 'Runner did not accept connection (simctl spawn)', { - port, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - reason, - hint: bootFailureHint(reason), - }); + throw new AppError( + 'COMMAND_FAILED', + 'Runner did not accept connection (simctl spawn)', + execFailureDetails(result, { + port, + reason, + hint: bootFailureHint(reason), + }), + ); } return { status: 200, body }; } diff --git a/src/platforms/apple/core/screenshot-status-bar.ts b/src/platforms/apple/core/screenshot-status-bar.ts index 3c884bc8f..83288ff9e 100644 --- a/src/platforms/apple/core/screenshot-status-bar.ts +++ b/src/platforms/apple/core/screenshot-status-bar.ts @@ -1,7 +1,7 @@ import type { DeviceInfo } from '../../../kernel/device.ts'; import { emitDiagnostic } from '../../../utils/diagnostics.ts'; import { AppError } from '../../../kernel/errors.ts'; -import type { ExecOptions } from '../../../utils/exec.ts'; +import { execFailureDetails, type ExecOptions } from '../../../utils/exec.ts'; import { runSimctlForDevice } from './simctl.ts'; import { extractAppleToolErrorMeta } from './tool-diagnostics.ts'; @@ -132,11 +132,11 @@ async function readSimulatorStatusBarOverrides( allowFailure: true, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to read simulator status bar overrides', { - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read simulator status bar overrides', + execFailureDetails(result), + ); } return parseSimulatorStatusBarOverrides(result.stdout); } diff --git a/src/platforms/apple/core/simulator.ts b/src/platforms/apple/core/simulator.ts index 456752962..a41ff6ded 100644 --- a/src/platforms/apple/core/simulator.ts +++ b/src/platforms/apple/core/simulator.ts @@ -1,5 +1,6 @@ import type { DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../utils/exec.ts'; import { Deadline, retryWithPolicy } from '../../../utils/retry.ts'; import { createTtlMemo } from '../../../utils/ttl-memo.ts'; import { bootFailureHint, classifyBootFailure } from '../../boot-diagnostics.ts'; @@ -134,11 +135,11 @@ export async function ensureBootedSimulator( bootOutput.includes('already booted') || bootOutput.includes('current state: booted'); if (bootResult.exitCode !== 0 && !bootAlreadyDone) { - throw new AppError('COMMAND_FAILED', 'simctl boot failed', { - stdout: bootResult.stdout, - stderr: bootResult.stderr, - exitCode: bootResult.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'simctl boot failed', + execFailureDetails(bootResult), + ); } const bootStatus = await runXcrun( @@ -155,11 +156,11 @@ export async function ensureBootedSimulator( }; if (bootStatusResult.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', { - stdout: bootStatusResult.stdout, - stderr: bootStatusResult.stderr, - exitCode: bootStatusResult.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'simctl bootstatus failed', + execFailureDetails(bootStatusResult), + ); } const nextState = await getSimulatorState(device); diff --git a/src/platforms/apple/os/macos/host-provider.ts b/src/platforms/apple/os/macos/host-provider.ts index bcde52098..82727b9eb 100644 --- a/src/platforms/apple/os/macos/host-provider.ts +++ b/src/platforms/apple/os/macos/host-provider.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import type { AppsFilter } from '../../../../contracts/app-inventory.ts'; import { AppError } from '../../../../kernel/errors.ts'; +import { execFailureDetails } from '../../../../utils/exec.ts'; import { filterAppleAppsByBundlePrefix } from '../../core/app-filter.ts'; import type { IosAppInfo } from '../../core/app-info.ts'; import type { @@ -26,11 +27,11 @@ export function createLocalAppleMacOsHostProvider( readClipboard: async () => { const result = await runCommand('pbpaste', [], { allowFailure: true }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to read macOS clipboard', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read macOS clipboard', + execFailureDetails(result), + ); } return result.stdout.replace(/\r\n/g, '\n').replace(/\n$/, ''); }, @@ -40,11 +41,11 @@ export function createLocalAppleMacOsHostProvider( stdin: text, }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to write macOS clipboard', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to write macOS clipboard', + execFailureDetails(result), + ); } }, readDarkMode: async () => { @@ -52,11 +53,11 @@ export function createLocalAppleMacOsHostProvider( 'tell application "System Events" to tell appearance preferences to get dark mode'; const result = await runCommand('osascript', ['-e', script], { allowFailure: true }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to read macOS appearance', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read macOS appearance', + execFailureDetails(result), + ); } const normalized = result.stdout.trim().toLowerCase(); if (normalized === 'true') return true; @@ -70,11 +71,11 @@ export function createLocalAppleMacOsHostProvider( const script = `tell application "System Events" to tell appearance preferences to set dark mode to ${enabled ? 'true' : 'false'}`; const result = await runCommand('osascript', ['-e', script], { allowFailure: true }); if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to set macOS appearance', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to set macOS appearance', + execFailureDetails(result), + ); } }, listApps: async (filter) => await listLocalMacApps(runCommand, readPlistJson, filter), diff --git a/src/platforms/web/agent-browser-provider.ts b/src/platforms/web/agent-browser-provider.ts index ff6bec12e..b8c85a20d 100644 --- a/src/platforms/web/agent-browser-provider.ts +++ b/src/platforms/web/agent-browser-provider.ts @@ -1,4 +1,4 @@ -import { runCmd } from '../../utils/exec.ts'; +import { execFailureDetails, runCmd } from '../../utils/exec.ts'; import { AppError } from '../../kernel/errors.ts'; import { sleep } from '../../utils/timeouts.ts'; import type { Rect } from '../../kernel/snapshot.ts'; @@ -253,14 +253,17 @@ function unwrapAgentBrowserJson( }); } if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'agent-browser command failed', { - cmd: AGENT_BROWSER, - args: cliArgs, - exitCode: result.exitCode, - stdout: result.stdout.slice(0, 500), - stderr: result.stderr.slice(0, 500), - hint: readStringProperty(parsed, 'hint') ?? AGENT_BROWSER_DOCTOR_HINT, - }); + throw new AppError( + 'COMMAND_FAILED', + 'agent-browser command failed', + execFailureDetails(result, { + cmd: AGENT_BROWSER, + args: cliArgs, + stdout: result.stdout.slice(0, 500), + stderr: result.stderr.slice(0, 500), + hint: readStringProperty(parsed, 'hint') ?? AGENT_BROWSER_DOCTOR_HINT, + }), + ); } return Object.hasOwn(parsed, 'data') ? parsed.data : parsed; diff --git a/src/utils/__tests__/errors.test.ts b/src/utils/__tests__/errors.test.ts index 08ad8c934..f14e445c9 100644 --- a/src/utils/__tests__/errors.test.ts +++ b/src/utils/__tests__/errors.test.ts @@ -31,6 +31,26 @@ test('normalizeError enriches generic command-failed message with stderr excerpt assert.equal(normalized.message, 'Operation not permitted'); }); +test('normalizeError appends stderr excerpt to specific command-failed messages', () => { + const err = new AppError('COMMAND_FAILED', 'uiautomator dump did not return XML', { + exitCode: 1, + processExitError: true, + stderr: 'uiautomator unavailable\n', + }); + const normalized = normalizeError(err); + assert.equal(normalized.message, 'uiautomator dump did not return XML: uiautomator unavailable'); +}); + +test('normalizeError does not duplicate an excerpt already present in the message', () => { + const err = new AppError('COMMAND_FAILED', 'simctl boot failed: device is locked', { + exitCode: 1, + processExitError: true, + stderr: 'device is locked\n', + }); + const normalized = normalizeError(err); + assert.equal(normalized.message, 'simctl boot failed: device is locked'); +}); + test('normalizeError skips simctl boilerplate wrappers in stderr', () => { const err = new AppError('COMMAND_FAILED', 'xcrun exited with code 1', { exitCode: 1, diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 85eaa4c37..68e443d0b 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -582,14 +582,29 @@ function createExitError( stdout: string, stderr: string, ): AppError { - return new AppError('COMMAND_FAILED', `${executable} exited with code ${exitCode}`, { - cmd, - args, - stdout, - stderr, - exitCode, + return new AppError( + 'COMMAND_FAILED', + `${executable} exited with code ${exitCode}`, + execFailureDetails({ stdout, stderr, exitCode }, { cmd, args }), + ); +} + +/** + * COMMAND_FAILED details for a non-zero exec result. `processExitError: true` + * lets normalizeError surface the first meaningful stderr line as the user-facing + * message instead of the generic wrap message. + */ +export function execFailureDetails( + result: Pick, + extra?: Record, +): Record { + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, processExitError: true, - }); + ...extra, + }; } type CommandAbort = { readonly didAbort: boolean }; diff --git a/test/integration/provider-scenarios/provider-failures.test.ts b/test/integration/provider-scenarios/provider-failures.test.ts index 2664dd123..27c2891d2 100644 --- a/test/integration/provider-scenarios/provider-failures.test.ts +++ b/test/integration/provider-scenarios/provider-failures.test.ts @@ -30,7 +30,10 @@ test('Provider-backed integration normalizes provider failures through the reque assert.equal(response.statusCode, 200); assert.equal(response.json?.error?.data?.code, 'COMMAND_FAILED'); - assert.match(response.json?.error?.message ?? '', /uiautomator dump did not return XML/i); + assert.match( + response.json?.error?.message ?? '', + /uiautomator dump did not return XML: uiautomator unavailable/i, + ); assert.equal(typeof response.json?.error?.data?.diagnosticId, 'string'); assert.ok( adbCalls.some((call) => call.join(' ') === 'exec-out uiautomator dump /dev/tty'),