From e30d2c526cd353dc57707e4583c5ea1cfa01ad68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:40:50 +0200 Subject: [PATCH 1/4] fix(android): mark every adb host refusal and report dispatched "no" for it device_unauthorized, multiple_devices, no_devices, device_not_found and server_version_mismatch now carry hostRefusal like device_offline, so attachAdbFailureHint stamps details.adbHostRefusal for all six. The adb input producers (discloseAdbInputDispatch) and the one-shot and session helper gesture producers read that stamp instead of only TOOL_MISSING; a later step still overrides to unknown through discloseDispatchAfterSteps. The one-shot helper launch failure now runs through attachAdbFailureHint so the classifier stays the single source. Tests and the mutation that makes each fail: - adb-failure.test.ts "every host-refusal reason ...": drop hostRefusal from the no_devices matcher. - adb-failure.test.ts "reasons a device-side command ...": add hostRefusal to connection_dropped or any install_* matcher. - dispatch-disclosure.test.ts android-adb.input-tap.host-refused.* (six rows, real executor via withFakeAdb): make discloseAdbInputDispatch ignore adbHostRefusal (6 failures). - dispatch-disclosure.test.ts android-helper.gesture.host-refused: drop attachAdbFailureHint from the one-shot no-parseable-output branch. - android-adb.input-tap.failed now scripts a non-refusal stderr and stays unknown. --- contracts/fixtures/dispatch-disclosure.json | 44 ++++++++++++++++++- .../src/__tests__/dispatch-disclosure.test.ts | 27 +++++++++++- .../platform-android/src/adb-failure.test.ts | 41 +++++++++++++++++ packages/platform-android/src/adb-failure.ts | 17 ++++++- .../src/snapshot-helper-session.ts | 9 ++-- packages/platform-android/src/touch-helper.ts | 18 ++++---- 6 files changed, 140 insertions(+), 16 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index acf40bb0f2..eed65cc97e 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -340,9 +340,45 @@ { "id": "android-adb.input-tap.failed", "producer": "android-adb", - "trigger": "adb input tap started and failed", + "trigger": "adb input tap started and failed with an exit the host adb did not refuse", "dispatched": "unknown" }, + { + "id": "android-adb.input-tap.host-refused.device-unauthorized", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (device_unauthorized) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.device-offline", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (device_offline) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.multiple-devices", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (multiple_devices) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.no-devices", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (no_devices) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.device-not-found", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (device_not_found) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.server-version-mismatch", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (server_version_mismatch) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, { "id": "android-adb.input-tap.double-tap-second-refused", "producer": "android-adb", @@ -391,6 +427,12 @@ "trigger": "one-shot helper gesture failed before returning parseable output", "dispatched": "unknown" }, + { + "id": "android-helper.gesture.host-refused", + "producer": "android-helper", + "trigger": "one-shot helper gesture: the host adb refused the am instrument launch (device unauthorized) before it reached the device", + "dispatched": "no" + }, { "id": "android-helper.ime.broadcast-tool-missing", "producer": "android-helper", diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts index 458fce8dfc..168b913411 100644 --- a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -183,13 +183,30 @@ const HELPER_REPORTED_FAILURE = resultRecord({ }); const HELPER_RESULT = resultRecord({ ok: 'true', kind: 'swipe', injectedEvents: '4' }); +/** The host adb's stderr for each refusal the classifier marks `hostRefusal`, keyed by row suffix. */ +const HOST_REFUSAL_STDERR: Record = { + 'device-unauthorized': + "error: device unauthorized.\nThis adb server's $ADB_VENDOR_KEYS is not set", + 'device-offline': 'error: device offline', + 'multiple-devices': 'error: more than one device/emulator', + 'no-devices': 'adb: no devices/emulators found', + 'device-not-found': "error: device 'emulator-5554' not found", + 'server-version-mismatch': "adb server version (40) doesn't match this client (41); killing...", +}; + const DRIVERS: Record Promise; dispatchedSteps?: number }> = { 'android-adb.input-tap.tool-missing': { drive: () => tapWithAdbAnswer(new AppError('TOOL_MISSING', 'adb not found in PATH')), }, 'android-adb.input-tap.failed': { - drive: () => tapWithAdbAnswer({ exitCode: 1, stderr: 'error: device offline' }), + drive: () => tapWithAdbAnswer({ exitCode: 1, stderr: 'Killed' }), }, + ...Object.fromEntries( + Object.entries(HOST_REFUSAL_STDERR).map(([name, stderr]) => [ + `android-adb.input-tap.host-refused.${name}`, + { drive: () => tapWithAdbAnswer({ exitCode: 1, stderr }) }, + ]), + ), 'android-adb.input-tap.double-tap-second-refused': { drive: doubleTapWithSecondTapRefused, dispatchedSteps: 1, @@ -228,6 +245,14 @@ const DRIVERS: Record Promise; dispatchedSteps?: 'android-helper.gesture.failed-after-result': { drive: () => oneShotGesture(async () => ({ exitCode: 1, stdout: HELPER_RESULT, stderr: '' })), }, + 'android-helper.gesture.host-refused': { + drive: () => + oneShotGesture(async () => ({ + exitCode: 1, + stdout: '', + stderr: 'error: device unauthorized.', + })), + }, 'android-helper.gesture.no-parseable-output': { drive: () => oneShotGesture(async () => ({ exitCode: 1, stdout: '', stderr: 'boom' })), }, diff --git a/packages/platform-android/src/adb-failure.test.ts b/packages/platform-android/src/adb-failure.test.ts index 3e04f31270..cc3e20d433 100644 --- a/packages/platform-android/src/adb-failure.test.ts +++ b/packages/platform-android/src/adb-failure.test.ts @@ -55,6 +55,47 @@ test('only the bare host adb device-offline refusal is classified as a host refu assert.equal(Object.hasOwn(timedOut.details ?? {}, 'adbHostRefusal'), false); }); +const HOST_REFUSALS = [ + ['device_unauthorized', 'error: device unauthorized.'], + ['device_offline', 'error: device offline'], + ['multiple_devices', 'error: more than one device/emulator'], + ['no_devices', 'adb: no devices/emulators found'], + ['device_not_found', "error: device 'emulator-5554' not found"], + ['server_version_mismatch', "adb server version (40) doesn't match this client (41); killing..."], +] as const; + +const DEVICE_SIDE_FAILURES = [ + ['connection_dropped', 'adb: error: protocol fault', ''], + ['install_insufficient_storage', '', 'Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]'], + ['install_update_incompatible', '', 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]'], + ['install_version_downgrade', '', 'Failure [INSTALL_FAILED_VERSION_DOWNGRADE]'], + ['install_failed', '', 'Failure [INSTALL_FAILED_DEXOPT]'], +] as const; + +test('every host-refusal reason is classified hostRefusal and stamped as adbHostRefusal', () => { + for (const [reason, stderr] of HOST_REFUSALS) { + assert.equal(classifyAndroidAdbFailure(stderr)?.reason, reason); + assert.equal(classifyAndroidAdbFailure(stderr)?.hostRefusal, true, reason); + const error = attachAdbFailureHint( + new AppError('COMMAND_FAILED', 'adb exited with code 1', { stderr }), + ); + assert.equal(error.details?.adbFailure, reason); + assert.equal(error.details?.adbHostRefusal, true, reason); + } +}); + +test('reasons a device-side command can produce are never classified hostRefusal', () => { + for (const [reason, stderr, stdout] of DEVICE_SIDE_FAILURES) { + const failure = classifyAndroidAdbFailure(stderr, stdout); + assert.equal(failure?.reason, reason); + assert.equal(failure?.hostRefusal, undefined, reason); + const error = attachAdbFailureHint( + new AppError('COMMAND_FAILED', 'adb exited with code 1', { stderr, stdout }), + ); + assert.equal(Object.hasOwn(error.details ?? {}, 'adbHostRefusal'), false, reason); + } +}); + test('ADB discovery classifies transport failures from stderr without trusting stdout', () => { const stdoutOnly = androidDiscoveryCommandError( 'adb devices failed', diff --git a/packages/platform-android/src/adb-failure.ts b/packages/platform-android/src/adb-failure.ts index 76839dade1..c2379cb1a0 100644 --- a/packages/platform-android/src/adb-failure.ts +++ b/packages/platform-android/src/adb-failure.ts @@ -46,6 +46,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'device_unauthorized', hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', + hostRefusal: true, }, ], [/device offline/, ANDROID_ADB_DEVICE_OFFLINE_FAILURE], @@ -54,6 +55,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'multiple_devices', hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', + hostRefusal: true, }, ], [ @@ -61,6 +63,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'no_devices', hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', + hostRefusal: true, }, ], [ @@ -68,6 +71,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'device_not_found', hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', + hostRefusal: true, retriable: true, }, ], @@ -76,6 +80,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'server_version_mismatch', hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.', + hostRefusal: true, retriable: true, }, ], @@ -282,8 +287,16 @@ export function attachAndroidDiscoveryTimeout(error: T): T { return error; } -/** One adb input send's failure: adb that never started delivered nothing; otherwise it may have. */ +/** One adb input send's failure: adb that never started or a host adb refusal delivered nothing; otherwise it may have. */ export function discloseAdbInputDispatch(error: unknown): unknown { if (!(error instanceof AppError)) return error; - return discloseDispatch(error, error.code === 'TOOL_MISSING' ? 'no' : 'unknown'); + return discloseDispatch( + error, + error.code === 'TOOL_MISSING' || isAdbHostRefusal(error) ? 'no' : 'unknown', + ); +} + +/** Whether the host adb refused the command before it reached the device, so it had no effect there. */ +export function isAdbHostRefusal(error: AppError): boolean { + return error.details?.adbHostRefusal === true; } diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index 3a4c51433f..09943069b3 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -4,6 +4,7 @@ * `snapshot-helper-session-lifecycle.ts`, which this module acquires through. */ import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; +import { isAdbHostRefusal } from './adb-failure.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { readAndroidCaptureFailureReason } from '@agent-device/contracts/android-snapshot-quality'; import type { @@ -93,16 +94,16 @@ export type AndroidTouchHelperAction = 'gesture' | 'viewport'; /** * Only a gesture injects input, so only its failures say whether input reached the device. Its - * failure is `unknown` on every path: an `ok=false` result carries the thrown `errorType` but no - * count of events injected before the throw, so a parse refusal and a mid-injection failure read - * alike. + * failure is `unknown` unless the host adb refused the launch: an `ok=false` result carries the + * thrown `errorType` but no count of events injected before the throw, so a parse refusal and a + * mid-injection failure read alike. */ export function discloseHelperTouchDispatch( action: AndroidTouchHelperAction, error: unknown, ): unknown { if (action !== 'gesture' || !(error instanceof AppError)) return error; - return discloseDispatch(error, 'unknown'); + return discloseDispatch(error, isAdbHostRefusal(error) ? 'no' : 'unknown'); } export async function runAndroidSnapshotHelperSessionTouchCommand(params: { diff --git a/packages/platform-android/src/touch-helper.ts b/packages/platform-android/src/touch-helper.ts index 5be52a45ad..192112cc44 100644 --- a/packages/platform-android/src/touch-helper.ts +++ b/packages/platform-android/src/touch-helper.ts @@ -2,6 +2,7 @@ import type { PointerTrajectory } from '@agent-device/contracts/gesture-plan-typ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Rect } from '@agent-device/kernel/snapshot'; import { AppError } from '@agent-device/kernel/errors'; +import { attachAdbFailureHint } from './adb-failure.ts'; import { execFailureDetails } from '@agent-device/host-kit/command'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; @@ -294,16 +295,17 @@ async function runOneShotTouchHelper(options: { } if (error.code !== HELPER_NO_FINAL_RESULT) throw error; } + const unparsed = new AppError( + 'COMMAND_FAILED', + result.exitCode === 0 + ? 'Android automation helper output could not be parsed' + : 'Android automation helper failed before returning parseable output', + execFailureDetails(result), + error, + ); throw discloseHelperTouchDispatch( options.action, - new AppError( - 'COMMAND_FAILED', - result.exitCode === 0 - ? 'Android automation helper output could not be parsed' - : 'Android automation helper failed before returning parseable output', - execFailureDetails(result), - error, - ), + result.exitCode === 0 ? unparsed : attachAdbFailureHint(unparsed), ); } if (result.exitCode !== 0) { From 115fe89714f9f33fa1450744787d12b13347d189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:45:31 +0200 Subject: [PATCH 2/4] fix(android): mark a host refusal only when the whole adb output is the refusal --- contracts/fixtures/dispatch-disclosure.json | 6 +- .../src/__tests__/dispatch-disclosure.test.ts | 8 ++- .../platform-android/src/adb-failure.test.ts | 24 ++++++- packages/platform-android/src/adb-failure.ts | 63 ++++++++++++++----- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index eed65cc97e..94b60c03d3 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -374,10 +374,10 @@ "dispatched": "no" }, { - "id": "android-adb.input-tap.host-refused.server-version-mismatch", + "id": "android-adb.input-tap.refusal-text-beside-other-output", "producer": "android-adb", - "trigger": "adb input tap refused by the host adb (server_version_mismatch) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", - "dispatched": "no" + "trigger": "adb input tap failed with refusal-like stderr that is not the whole output (server restart line, then a device-side exit); only a refusal that is the entire output makes it no", + "dispatched": "unknown" }, { "id": "android-adb.input-tap.double-tap-second-refused", diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts index 168b913411..b1c84d7ba4 100644 --- a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -191,7 +191,6 @@ const HOST_REFUSAL_STDERR: Record = { 'multiple-devices': 'error: more than one device/emulator', 'no-devices': 'adb: no devices/emulators found', 'device-not-found': "error: device 'emulator-5554' not found", - 'server-version-mismatch': "adb server version (40) doesn't match this client (41); killing...", }; const DRIVERS: Record Promise; dispatchedSteps?: number }> = { @@ -201,6 +200,13 @@ const DRIVERS: Record Promise; dispatchedSteps?: 'android-adb.input-tap.failed': { drive: () => tapWithAdbAnswer({ exitCode: 1, stderr: 'Killed' }), }, + 'android-adb.input-tap.refusal-text-beside-other-output': { + drive: () => + tapWithAdbAnswer({ + exitCode: 137, + stderr: "adb server version (40) doesn't match this client (41); killing...\nKilled", + }), + }, ...Object.fromEntries( Object.entries(HOST_REFUSAL_STDERR).map(([name, stderr]) => [ `android-adb.input-tap.host-refused.${name}`, diff --git a/packages/platform-android/src/adb-failure.test.ts b/packages/platform-android/src/adb-failure.test.ts index cc3e20d433..4f7daf2ec6 100644 --- a/packages/platform-android/src/adb-failure.test.ts +++ b/packages/platform-android/src/adb-failure.test.ts @@ -61,7 +61,6 @@ const HOST_REFUSALS = [ ['multiple_devices', 'error: more than one device/emulator'], ['no_devices', 'adb: no devices/emulators found'], ['device_not_found', "error: device 'emulator-5554' not found"], - ['server_version_mismatch', "adb server version (40) doesn't match this client (41); killing..."], ] as const; const DEVICE_SIDE_FAILURES = [ @@ -84,6 +83,29 @@ test('every host-refusal reason is classified hostRefusal and stamped as adbHost } }); +test('refusal text never proves a refusal unless it is the entire adb output', () => { + for (const [reason, refusal] of HOST_REFUSALS) { + for (const [stderr, stdout] of [ + [refusal, 'partial'], + [`${refusal}\nKilled`, ''], + [`adb server version (40) doesn't match this client (41); killing...\n${refusal}`, ''], + [`${refusal}, giving up`, ''], + ] as const) { + const failure = classifyAndroidAdbFailure(stderr, stdout); + assert.equal(failure?.hostRefusal, undefined, `${reason}: ${stderr} | ${stdout}`); + const error = attachAdbFailureHint( + new AppError('COMMAND_FAILED', 'adb exited with code 1', { stderr, stdout }), + ); + assert.equal(Object.hasOwn(error.details ?? {}, 'adbHostRefusal'), false, reason); + } + } + const mismatch = classifyAndroidAdbFailure( + "adb server version (40) doesn't match this client (41); killing...", + ); + assert.equal(mismatch?.reason, 'server_version_mismatch'); + assert.equal(mismatch?.hostRefusal, undefined); +}); + test('reasons a device-side command can produce are never classified hostRefusal', () => { for (const [reason, stderr, stdout] of DEVICE_SIDE_FAILURES) { const failure = classifyAndroidAdbFailure(stderr, stdout); diff --git a/packages/platform-android/src/adb-failure.ts b/packages/platform-android/src/adb-failure.ts index c2379cb1a0..3436d7aacd 100644 --- a/packages/platform-android/src/adb-failure.ts +++ b/packages/platform-android/src/adb-failure.ts @@ -32,21 +32,12 @@ const ANDROID_ADB_DEVICE_OFFLINE_FAILURE = { retriable: true, } as const satisfies AndroidAdbFailureClassification; -/** The entire stderr, lowercased, of a command the host adb refused because the device was offline. */ -const ADB_DEVICE_OFFLINE_HOST_REFUSAL = /^(?:adb|error): device (?:'[^']*' )?offline$/; - -const ANDROID_ADB_DEVICE_OFFLINE_HOST_REFUSAL: AndroidAdbFailureClassification = Object.freeze({ - ...ANDROID_ADB_DEVICE_OFFLINE_FAILURE, - hostRefusal: true, -}); - const ANDROID_ADB_FAILURE_MATCHERS = [ [ /device unauthorized|device still authorizing/, { reason: 'device_unauthorized', hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', - hostRefusal: true, }, ], [/device offline/, ANDROID_ADB_DEVICE_OFFLINE_FAILURE], @@ -55,7 +46,6 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'multiple_devices', hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', - hostRefusal: true, }, ], [ @@ -63,7 +53,6 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'no_devices', hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', - hostRefusal: true, }, ], [ @@ -71,7 +60,6 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'device_not_found', hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', - hostRefusal: true, retriable: true, }, ], @@ -80,7 +68,6 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ { reason: 'server_version_mismatch', hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.', - hostRefusal: true, retriable: true, }, ], @@ -134,15 +121,59 @@ const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.free const ANDROID_HELPER_INSTALL_TIMEOUT_HINT = 'The helper install timed out — some OEM builds (ColorOS is one) hold the first install of each package behind a system install-confirmation dialog and adb waits for it. Check the device screen, confirm any pending install prompt, then retry; if none is showing, run adb kill-server && adb start-server and retry.'; +type HostRefusalShape = readonly [head: RegExp, reason: AndroidAdbFailureReason]; + +const HOST_REFUSAL_PREFIX = String.raw`(?:adb|error): `; + +/** First stderr line, lowercased, of each refusal the host adb issues before the command leaves the host. */ +const HOST_REFUSAL_SHAPES: readonly HostRefusalShape[] = [ + [ + new RegExp(`^${HOST_REFUSAL_PREFIX}device (?:unauthorized|still authorizing)\\.?$`), + 'device_unauthorized', + ], + [new RegExp(`^${HOST_REFUSAL_PREFIX}device (?:'[^']*' )?offline$`), 'device_offline'], + [ + new RegExp(`^${HOST_REFUSAL_PREFIX}more than one (?:device/emulator|device and emulator)$`), + 'multiple_devices', + ], + [new RegExp(`^${HOST_REFUSAL_PREFIX}no devices(?:/emulators)? found$`), 'no_devices'], + [new RegExp(`^${HOST_REFUSAL_PREFIX}device (?:'[^']*' )?not found$`), 'device_not_found'], +]; + +/** The fixed trailer lines adb appends to a device_unauthorized refusal. */ +const HOST_REFUSAL_TRAILER = + /^(?:this adb server's \$adb_vendor_keys is not set|try 'adb kill-server' if that seems wrong\.|(?:otherwise|please) check (?:for|the) (?:a )?confirmation dialog on your device\.)$/; + +const ANDROID_ADB_FAILURES_BY_REASON = new Map< + AndroidAdbFailureReason, + AndroidAdbFailureClassification +>(ANDROID_ADB_FAILURE_MATCHERS.map(([, failure]) => [failure.reason, failure])); + +function classifyHostRefusal( + stderrText: string, + stdoutText: string, +): AndroidAdbFailureClassification | undefined { + if (stdoutText !== '') return undefined; + const [head, ...trailer] = stderrText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== ''); + if (head === undefined || !trailer.every((line) => HOST_REFUSAL_TRAILER.test(line))) { + return undefined; + } + const reason = HOST_REFUSAL_SHAPES.find(([shape]) => shape.test(head))?.[1]; + const failure = reason && ANDROID_ADB_FAILURES_BY_REASON.get(reason); + return failure && { ...failure, hostRefusal: true }; +} + export function classifyAndroidAdbFailure( stderr: string, stdout = '', ): AndroidAdbFailureClassification | undefined { const stderrText = stderr.toLowerCase(); const stdoutText = stdout.toLowerCase(); - if (stdoutText === '' && ADB_DEVICE_OFFLINE_HOST_REFUSAL.test(stderrText.trim())) { - return ANDROID_ADB_DEVICE_OFFLINE_HOST_REFUSAL; - } + const refusal = classifyHostRefusal(stderrText, stdoutText); + if (refusal) return refusal; for (const [pattern, classification, matchStdout] of ANDROID_ADB_FAILURE_MATCHERS) { if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { return classification; From b0a78214987865d25e571472d6d903ccb2f0fcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 22:13:25 +0200 Subject: [PATCH 3/4] fix(android): keep adb failure details on a parsed helper result with a nonzero exit - dispatch-disclosure fixture: the mixed-refusal row now feeds `error: device offline\nKilled` through the real tap executor and expects unknown. Mutation: ignoring non-trailer lines in classifyHostRefusal (trailer.every -> true) makes the row report no. - touch-helper: the parsed-final/nonzero error goes through attachAdbFailureHint before disclosure. Test: a nonzero exit with `adb: device offline` keeps adbFailure, hint, retriable. Mutation: dropping attachAdbFailureHint from that branch fails the new test (verified). - adb-failure.test: the device_offline hint is a literal string instead of a value derived from the classifier. Mutation: editing the hint text in adb-failure.ts fails the assertion. --- contracts/fixtures/dispatch-disclosure.json | 2 +- .../src/__tests__/dispatch-disclosure.test.ts | 4 +-- .../src/__tests__/touch-helper.test.ts | 27 +++++++++++++++++++ .../platform-android/src/adb-failure.test.ts | 2 +- packages/platform-android/src/touch-helper.ts | 10 ++++--- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 94b60c03d3..0a9038e3df 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -376,7 +376,7 @@ { "id": "android-adb.input-tap.refusal-text-beside-other-output", "producer": "android-adb", - "trigger": "adb input tap failed with refusal-like stderr that is not the whole output (server restart line, then a device-side exit); only a refusal that is the entire output makes it no", + "trigger": "adb input tap failed with a recognized refusal line followed by a non-trailer line (error: device offline, then Killed); only a refusal that is the entire output makes it no", "dispatched": "unknown" }, { diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts index b1c84d7ba4..c2ac1bad5c 100644 --- a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -203,8 +203,8 @@ const DRIVERS: Record Promise; dispatchedSteps?: 'android-adb.input-tap.refusal-text-beside-other-output': { drive: () => tapWithAdbAnswer({ - exitCode: 137, - stderr: "adb server version (40) doesn't match this client (41); killing...\nKilled", + exitCode: 1, + stderr: 'error: device offline\nKilled', }), }, ...Object.fromEntries( diff --git a/packages/platform-android/src/__tests__/touch-helper.test.ts b/packages/platform-android/src/__tests__/touch-helper.test.ts index 0315f93f30..4ea815ce13 100644 --- a/packages/platform-android/src/__tests__/touch-helper.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper.test.ts @@ -376,6 +376,33 @@ test('unparseable output with a non-zero exit code reports a helper failure', as ); }); +test('a parsed helper result with a non-zero exit keeps the recognized adb failure details', async () => { + const device = makeIsolatedDevice(); + await assert.rejects( + withAndroidAdbProvider( + { + exec: currentVersionAdb(async () => ({ + exitCode: 1, + stdout: [resultRecord({ ok: 'true', kind: 'swipe' }), 'INSTRUMENTATION_CODE: 0'].join( + '\n', + ), + stderr: 'adb: device offline', + })), + }, + { serial: device.id }, + async () => await executeAndroidTouchHelperPlan(device, lowerAndroidTouchPlan(flingPlan())), + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'Android automation helper failed'); + assert.equal(error.details?.adbFailure, 'device_offline'); + assert.equal(typeof error.details?.hint, 'string'); + assert.equal(error.details?.retriable, true); + return true; + }, + ); +}); + test('one-shot viewport instruments the snapshot-helper runner and validates bounds', async () => { const device = makeIsolatedDevice(); let capturedArgs: readonly string[] | undefined; diff --git a/packages/platform-android/src/adb-failure.test.ts b/packages/platform-android/src/adb-failure.test.ts index 4f7daf2ec6..3abfb62bb5 100644 --- a/packages/platform-android/src/adb-failure.test.ts +++ b/packages/platform-android/src/adb-failure.test.ts @@ -27,7 +27,7 @@ test('only the bare host adb device-offline refusal is classified as a host refu for (const stderr of ['adb: device offline\n', "error: device 'emulator-5554' offline"]) { assert.deepEqual(classifyAndroidAdbFailure(stderr), { reason: 'device_offline', - hint: classifyAndroidAdbFailure('device offline')?.hint, + hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', retriable: true, hostRefusal: true, }); diff --git a/packages/platform-android/src/touch-helper.ts b/packages/platform-android/src/touch-helper.ts index 192112cc44..4a62c3b195 100644 --- a/packages/platform-android/src/touch-helper.ts +++ b/packages/platform-android/src/touch-helper.ts @@ -311,10 +311,12 @@ async function runOneShotTouchHelper(options: { if (result.exitCode !== 0) { throw discloseHelperTouchDispatch( options.action, - new AppError( - 'COMMAND_FAILED', - 'Android automation helper failed', - execFailureDetails(result, { helper: finalRecord }), + attachAdbFailureHint( + new AppError( + 'COMMAND_FAILED', + 'Android automation helper failed', + execFailureDetails(result, { helper: finalRecord }), + ), ), ); } From a8bb438ecf77b54b03f4aa13a00f33688029a72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 08:45:01 +0200 Subject: [PATCH 4/4] refactor(android): one adb failure table carries the host-refusal shapes --- packages/platform-android/src/adb-failure.ts | 73 ++++++++++---------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/packages/platform-android/src/adb-failure.ts b/packages/platform-android/src/adb-failure.ts index 3436d7aacd..21bb53975e 100644 --- a/packages/platform-android/src/adb-failure.ts +++ b/packages/platform-android/src/adb-failure.ts @@ -23,30 +23,50 @@ export type AndroidAdbFailureClassification = Readonly<{ type AndroidAdbFailureMatcher = readonly [ pattern: RegExp, failure: AndroidAdbFailureClassification, - matchStdout?: true, + options?: Readonly<{ + matchStdout?: true; + /** Anchored shape of the host adb's first stderr line when it issues this refusal before the command leaves the host. */ + hostRefusal?: RegExp; + }>, ]; +const HOST_REFUSAL_PREFIX = String.raw`(?:adb|error): `; + +function hostRefusalHead(body: string): RegExp { + return new RegExp(`^${HOST_REFUSAL_PREFIX}${body}$`); +} + const ANDROID_ADB_DEVICE_OFFLINE_FAILURE = { reason: 'device_offline', hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', retriable: true, } as const satisfies AndroidAdbFailureClassification; -const ANDROID_ADB_FAILURE_MATCHERS = [ +const ANDROID_ADB_FAILURE_MATCHERS: readonly AndroidAdbFailureMatcher[] = [ [ /device unauthorized|device still authorizing/, { reason: 'device_unauthorized', hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', }, + { hostRefusal: hostRefusalHead(String.raw`device (?:unauthorized|still authorizing)\.?`) }, + ], + [ + /device offline/, + ANDROID_ADB_DEVICE_OFFLINE_FAILURE, + { hostRefusal: hostRefusalHead(String.raw`device (?:'[^']*' )?offline`) }, ], - [/device offline/, ANDROID_ADB_DEVICE_OFFLINE_FAILURE], [ /more than one (?:device\/emulator|device and emulator)/, { reason: 'multiple_devices', hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', }, + { + hostRefusal: hostRefusalHead( + String.raw`more than one (?:device/emulator|device and emulator)`, + ), + }, ], [ /no devices\/emulators found|no devices found/, @@ -54,6 +74,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'no_devices', hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', }, + { hostRefusal: hostRefusalHead(String.raw`no devices(?:/emulators)? found`) }, ], [ /device (?:'[^']*' )?not found/, @@ -62,6 +83,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', retriable: true, }, + { hostRefusal: hostRefusalHead(String.raw`device (?:'[^']*' )?not found`) }, ], [ /adb server version \(\d+\) doesn't match this client/, @@ -85,7 +107,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_insufficient_storage', hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.', }, - true, + { matchStdout: true }, ], [ /install_failed_update_incompatible/, @@ -93,7 +115,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_update_incompatible', hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.', }, - true, + { matchStdout: true }, ], [ /install_failed_version_downgrade/, @@ -101,7 +123,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_version_downgrade', hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.', }, - true, + { matchStdout: true }, ], [ /install_failed_\w+|install_parse_failed_\w+/, @@ -109,9 +131,9 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_failed', hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', }, - true, + { matchStdout: true }, ], -] as const satisfies readonly AndroidAdbFailureMatcher[]; +]; const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.freeze({ reason: 'timeout', @@ -121,34 +143,10 @@ const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.free const ANDROID_HELPER_INSTALL_TIMEOUT_HINT = 'The helper install timed out — some OEM builds (ColorOS is one) hold the first install of each package behind a system install-confirmation dialog and adb waits for it. Check the device screen, confirm any pending install prompt, then retry; if none is showing, run adb kill-server && adb start-server and retry.'; -type HostRefusalShape = readonly [head: RegExp, reason: AndroidAdbFailureReason]; - -const HOST_REFUSAL_PREFIX = String.raw`(?:adb|error): `; - -/** First stderr line, lowercased, of each refusal the host adb issues before the command leaves the host. */ -const HOST_REFUSAL_SHAPES: readonly HostRefusalShape[] = [ - [ - new RegExp(`^${HOST_REFUSAL_PREFIX}device (?:unauthorized|still authorizing)\\.?$`), - 'device_unauthorized', - ], - [new RegExp(`^${HOST_REFUSAL_PREFIX}device (?:'[^']*' )?offline$`), 'device_offline'], - [ - new RegExp(`^${HOST_REFUSAL_PREFIX}more than one (?:device/emulator|device and emulator)$`), - 'multiple_devices', - ], - [new RegExp(`^${HOST_REFUSAL_PREFIX}no devices(?:/emulators)? found$`), 'no_devices'], - [new RegExp(`^${HOST_REFUSAL_PREFIX}device (?:'[^']*' )?not found$`), 'device_not_found'], -]; - /** The fixed trailer lines adb appends to a device_unauthorized refusal. */ const HOST_REFUSAL_TRAILER = /^(?:this adb server's \$adb_vendor_keys is not set|try 'adb kill-server' if that seems wrong\.|(?:otherwise|please) check (?:for|the) (?:a )?confirmation dialog on your device\.)$/; -const ANDROID_ADB_FAILURES_BY_REASON = new Map< - AndroidAdbFailureReason, - AndroidAdbFailureClassification ->(ANDROID_ADB_FAILURE_MATCHERS.map(([, failure]) => [failure.reason, failure])); - function classifyHostRefusal( stderrText: string, stdoutText: string, @@ -161,9 +159,10 @@ function classifyHostRefusal( if (head === undefined || !trailer.every((line) => HOST_REFUSAL_TRAILER.test(line))) { return undefined; } - const reason = HOST_REFUSAL_SHAPES.find(([shape]) => shape.test(head))?.[1]; - const failure = reason && ANDROID_ADB_FAILURES_BY_REASON.get(reason); - return failure && { ...failure, hostRefusal: true }; + const row = ANDROID_ADB_FAILURE_MATCHERS.find(([, , options]) => + options?.hostRefusal?.test(head), + ); + return row && { ...row[1], hostRefusal: true }; } export function classifyAndroidAdbFailure( @@ -174,8 +173,8 @@ export function classifyAndroidAdbFailure( const stdoutText = stdout.toLowerCase(); const refusal = classifyHostRefusal(stderrText, stdoutText); if (refusal) return refusal; - for (const [pattern, classification, matchStdout] of ANDROID_ADB_FAILURE_MATCHERS) { - if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { + for (const [pattern, classification, options] of ANDROID_ADB_FAILURE_MATCHERS) { + if (pattern.test(stderrText) || (options?.matchStdout && pattern.test(stdoutText))) { return classification; } }