From 89d742e3d3d071c4415c9e526a240debc4ecb11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 4 Jul 2026 09:03:14 +0200 Subject: [PATCH 1/2] fix: surface stderr excerpts from hand-rolled exec failure wraps normalizeError only replaces a generic COMMAND_FAILED message with the first meaningful stderr line when details.processExitError is true, but only createExitError in utils/exec.ts set that flag. Hand-rolled wrap sites (throw new AppError('COMMAND_FAILED', msg, {stdout, stderr, exitCode}) after an allowFailure run) missed it, so users saw messages like "xcrun exited with code 22" with a generic hint instead of the actual stderr excerpt. Add execFailureDetails(result, extra?) to utils/exec.ts returning the stdout/stderr/exitCode spread plus processExitError: true (extras spread after the base keys so call sites can keep truncation or String() normalization), and convert ~42 exit-guarded wrap sites across the apple platform (apps launch/terminate paths, devicectl, simulator boot, perf, perf-xctrace, runner modules, dsym/symbolication, macOS host provider) and android/web (app-helpers, logcat, devices, perf, settings, snapshot helpers, app-lifecycle, device-input-state, multitouch-helper, agent-browser provider). Sites reachable at exit 0 (parse/stdout checks), failure aggregations, timeout/forced-kill errors, and sites that already build richer messages (macos helper JSON, runner-contract early-exit, maestro run-script) are deliberately left unflagged so stderr enrichment never replaces a more specific message. --- src/platforms/android/app-helpers.ts | 11 +-- src/platforms/android/app-lifecycle.ts | 12 +-- src/platforms/android/device-input-state.ts | 21 ++--- src/platforms/android/devices.ts | 15 ++-- src/platforms/android/logcat.ts | 11 +-- src/platforms/android/multitouch-helper.ts | 24 +++--- src/platforms/android/perf.ts | 31 ++++--- src/platforms/android/settings.ts | 11 +-- .../android/snapshot-helper-capture.ts | 12 +-- .../android/snapshot-helper-install.ts | 13 ++- src/platforms/android/snapshot.ts | 12 +-- src/platforms/apple/core/app-device-io.ts | 21 ++--- src/platforms/apple/core/app-install.ts | 32 ++++--- src/platforms/apple/core/app-launch.ts | 25 +++--- src/platforms/apple/core/app-settings.ts | 21 ++--- .../apple/core/debug-symbols/dsym.ts | 11 +-- .../apple/core/debug-symbols/symbolication.ts | 17 ++-- src/platforms/apple/core/devicectl.ts | 43 ++++++---- src/platforms/apple/core/perf-xctrace.ts | 58 +++++++------ src/platforms/apple/core/perf.ts | 85 +++++++++---------- .../apple/core/runner/runner-artifact-env.ts | 10 ++- .../apple/core/runner/runner-icon.ts | 20 +++-- .../apple/core/runner/runner-transport.ts | 18 ++-- .../apple/core/screenshot-status-bar.ts | 12 +-- src/platforms/apple/core/simulator.ts | 21 ++--- src/platforms/apple/os/macos/host-provider.ts | 41 ++++----- src/platforms/web/agent-browser-provider.ts | 21 +++-- src/utils/exec.ts | 29 +++++-- 28 files changed, 351 insertions(+), 307 deletions(-) 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/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 }; From c02c33ce5cf83869ec1744d911c99a208f49b949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 4 Jul 2026 10:06:09 +0200 Subject: [PATCH 2/2] fix: append stderr excerpts to curated wrap messages instead of replacing them Spreading processExitError to curated call sites made normalizeError replace specific messages like 'uiautomator dump did not return XML' with the bare stderr line, losing the operation context (caught by the provider-failures integration test). Keep wholesale replacement only for the information-free generic ' exited with code N' wraps; curated messages now keep their specific reason and gain the stderr excerpt as a ': ' suffix (skipped when already present). Separate commit rather than amend so the semantics change to maybeEnrichCommandFailedMessage stays individually reviewable on the PR. --- src/kernel/errors.ts | 9 ++++++++- src/utils/__tests__/errors.test.ts | 20 +++++++++++++++++++ .../provider-failures.test.ts | 5 ++++- 3 files changed, 32 insertions(+), 2 deletions(-) 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/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/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'),