Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion contracts/fixtures/dispatch-disclosure.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,43 @@
{
"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.refusal-text-beside-other-output",
"producer": "android-adb",
"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"
},
{
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,36 @@ 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<string, string> = {
'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",
};

const DRIVERS: Record<string, { drive: () => Promise<unknown>; 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' }),
},
'android-adb.input-tap.refusal-text-beside-other-output': {
drive: () =>
tapWithAdbAnswer({
exitCode: 1,
stderr: 'error: device offline\nKilled',
}),
},
...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,
Expand Down Expand Up @@ -228,6 +251,14 @@ const DRIVERS: Record<string, { drive: () => Promise<unknown>; 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' })),
},
Expand Down
27 changes: 27 additions & 0 deletions packages/platform-android/src/__tests__/touch-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 64 additions & 1 deletion packages/platform-android/src/adb-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -55,6 +55,69 @@ test('only the bare host adb device-offline refusal is classified as a host refu
assert.equal(Object.hasOwn(timedOut.details ?? {}, 'adbHostRefusal'), false);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
});

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"],
] 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('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);
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',
Expand Down
Loading
Loading