Skip to content

Commit 95ec62f

Browse files
committed
refactor(android): enforce host-adb transport failure contract
1 parent 6015a72 commit 95ec62f

4 files changed

Lines changed: 118 additions & 48 deletions

File tree

src/platforms/android/__tests__/adb-host-transport.test.ts

Lines changed: 34 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import { AppError } from '@agent-device/kernel/errors';
44
import { runAndroidHostAdb, withAndroidHostAdbTransport } from '../adb-host-transport.ts';
5-
import { listAndroidAdbSerialsQuick } from '../ime-lifecycle.ts';
6-
import { createLimrunRuntimeDependencies } from '../../../sdk/limrun-runtime-dependencies.ts';
75
import {
86
withCommandExecutorOverride,
97
type CommandExecutorOverride,
@@ -70,10 +68,12 @@ test('without a transport host adb falls back to local runCmd with argv and opti
7068
});
7169

7270
test('transport results and errors pass through with their ExecResult shape', async () => {
71+
// Nonzero results only pass through under allowFailure; the throw contract
72+
// has its own tests below.
7373
const scripted: ExecResult = { stdout: '', stderr: 'boom', exitCode: 7 };
7474
const result = await withAndroidHostAdbTransport(
7575
async () => scripted,
76-
async () => await runAndroidHostAdb(['devices']),
76+
async () => await runAndroidHostAdb(['devices'], { allowFailure: true }),
7777
);
7878
assert.deepEqual(result, scripted);
7979

@@ -82,7 +82,7 @@ test('transport results and errors pass through with their ExecResult shape', as
8282
const sloppy = { stdout: undefined, stderr: null, exitCode: '1' } as unknown as ExecResult;
8383
const coerced = await withAndroidHostAdbTransport(
8484
async () => sloppy,
85-
async () => await runAndroidHostAdb(['devices']),
85+
async () => await runAndroidHostAdb(['devices'], { allowFailure: true }),
8686
);
8787
assert.deepEqual(coerced, { stdout: '', stderr: '', exitCode: 1 });
8888

@@ -98,6 +98,36 @@ test('transport results and errors pass through with their ExecResult shape', as
9898
);
9999
});
100100

101+
test('a transport nonzero exit without allowFailure throws the classified adb failure', async () => {
102+
const error = await withAndroidHostAdbTransport(
103+
async () => ({ stdout: '', stderr: 'error: device offline', exitCode: 1 }),
104+
async () =>
105+
await runAndroidHostAdb(['devices']).then(
106+
() => assert.fail('expected the host adb call to reject'),
107+
(err: unknown) => err,
108+
),
109+
);
110+
111+
assert.ok(error instanceof AppError);
112+
assert.equal(error.code, 'COMMAND_FAILED');
113+
assert.equal(error.message, 'adb devices exited with code 1');
114+
assert.equal(error.details?.adbFailure, 'device_offline');
115+
assert.equal(error.details?.retriable, true);
116+
assert.match(String(error.details?.hint), /adb reconnect/i);
117+
assert.equal(error.details?.stderr, 'error: device offline');
118+
});
119+
120+
test('a transport nonzero exit with allowFailure returns the result untouched', async () => {
121+
const scripted: ExecResult = { stdout: '', stderr: 'error: device offline', exitCode: 1 };
122+
123+
const result = await withAndroidHostAdbTransport(
124+
async () => scripted,
125+
async () => await runAndroidHostAdb(['devices'], { allowFailure: true }),
126+
);
127+
128+
assert.deepEqual(result, scripted);
129+
});
130+
101131
test('nested transport scopes compose innermost-wins and restore on scope end', async () => {
102132
const calls: string[] = [];
103133
const transportFor = (name: string) => async () => {
@@ -120,44 +150,3 @@ test('nested transport scopes compose innermost-wins and restore on scope end',
120150
assert.deepEqual(calls, ['outer', 'inner', 'outer']);
121151
assert.equal(driver.calls.length, 1);
122152
});
123-
124-
test('listAndroidAdbSerialsQuick routes its global devices call through the transport', async () => {
125-
const seenArgs: string[][] = [];
126-
127-
const serials = await withAndroidHostAdbTransport(
128-
async (args) => {
129-
seenArgs.push([...args]);
130-
return {
131-
stdout: 'List of devices attached\nemulator-5554\tdevice\n',
132-
stderr: '',
133-
exitCode: 0,
134-
};
135-
},
136-
async () => await listAndroidAdbSerialsQuick(),
137-
);
138-
139-
assert.deepEqual(serials, ['emulator-5554']);
140-
assert.deepEqual(seenArgs, [['devices']]);
141-
});
142-
143-
test('limrun host.runAdb keeps its exported shape and routes through the transport', async () => {
144-
const dependencies = createLimrunRuntimeDependencies();
145-
const seen: Array<{ args: string[]; options?: ExecOptions }> = [];
146-
147-
const result = await withAndroidHostAdbTransport(
148-
async (args, options) => {
149-
seen.push({ args, ...(options ? { options } : {}) });
150-
return { stdout: 'ok', stderr: '', exitCode: 0 };
151-
},
152-
async () =>
153-
await dependencies.host.runAdb(['disconnect', 'emulator-5554'], {
154-
allowFailure: true,
155-
timeoutMs: 10_000,
156-
}),
157-
);
158-
159-
assert.deepEqual(result, { stdout: 'ok', stderr: '', exitCode: 0 });
160-
assert.deepEqual(seen, [
161-
{ args: ['disconnect', 'emulator-5554'], options: { allowFailure: true, timeoutMs: 10_000 } },
162-
]);
163-
});

src/platforms/android/__tests__/ime-lifecycle.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ vi.mock('../ime-helper.ts', async (importOriginal) => {
3737

3838
import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts';
3939
import { withAndroidAdbProvider, type AndroidAdbExecutor } from '../adb-executor.ts';
40+
import { withAndroidHostAdbTransport } from '../adb-host-transport.ts';
4041
import { resetAndroidImeHelperInstallCache } from '../ime-helper.ts';
4142
import {
4243
activateAndroidTestIme,
4344
isAndroidTestImeActive,
45+
listAndroidAdbSerialsQuick,
4446
restoreAndroidTestIme,
4547
restoreOrphanedAndroidTestImeOnDaemonStartup,
4648
resetAndroidTestImeActivationCacheForTests,
@@ -492,3 +494,33 @@ test('startup recovery tolerates a serial listing failure and keeps the marker',
492494
assert.equal(await pendingMarkerExists(stateDir, SERIAL), true);
493495
});
494496
});
497+
498+
test('listAndroidAdbSerialsQuick routes its global devices call through the host transport', async () => {
499+
const seenArgs: string[][] = [];
500+
501+
const serials = await withAndroidHostAdbTransport(
502+
async (args) => {
503+
seenArgs.push([...args]);
504+
return {
505+
stdout: 'List of devices attached\nemulator-5554\tdevice\n',
506+
stderr: '',
507+
exitCode: 0,
508+
};
509+
},
510+
async () => await listAndroidAdbSerialsQuick(),
511+
);
512+
513+
assert.deepEqual(serials, ['emulator-5554']);
514+
assert.deepEqual(seenArgs, [['devices']]);
515+
});
516+
517+
test('listAndroidAdbSerialsQuick yields no serials when the transport exits nonzero', async () => {
518+
const serials = await withAndroidHostAdbTransport(
519+
async () => ({ stdout: '', stderr: 'error: device offline', exitCode: 1 }),
520+
async () => await listAndroidAdbSerialsQuick(),
521+
);
522+
523+
// The seam throws on nonzero (no allowFailure here); the quick listing's
524+
// catch treats any failure as an empty inventory.
525+
assert.deepEqual(serials, []);
526+
});

src/platforms/android/adb-host-transport.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import { AsyncLocalStorage } from 'node:async_hooks';
22
import { coerceExecResult, runCmd, type ExecOptions, type ExecResult } from '../../utils/exec.ts';
3+
import { androidAdbResultError } from './adb-executor.ts';
34

45
/**
56
* Runs one host-level adb invocation — global (`adb devices`) or device-scoped
67
* (`adb -s <serial> …`). Providers install a scoped transport so host-level adb
78
* traffic follows their tunnel instead of shelling out to local adb.
9+
*
10+
* Precedence: `runAndroidHostAdb` consults an installed transport BEFORE the
11+
* exec layer, so a transport wins over any surrounding AndroidAdbProvider
12+
* command-executor override — including `-s <serial>` argv belonging to other
13+
* devices and global commands. Installers must therefore install outside
14+
* request-bound provider scopes, or filter serials inside the implementation.
15+
*
16+
* Implementations receive the raw argv exactly as the host would run it and are
17+
* held to the same allowFailure/throw-on-nonzero contract the local fallback
18+
* provides: the seam enforces it by throwing the classified adb-failure error
19+
* on a nonzero exit unless `options.allowFailure` is set.
820
*/
921
export type AndroidAdbHostTransport = (
1022
args: string[],
@@ -15,14 +27,25 @@ const androidAdbHostTransportScope = new AsyncLocalStorage<AndroidAdbHostTranspo
1527

1628
// Single construction path for host adb. Without an installed transport this
1729
// must stay plain `runCmd('adb', …)` so the exec layer's command-executor
18-
// interception (provider scopes) sees the call exactly as before.
30+
// interception (provider scopes) sees the call exactly as before. Both arms
31+
// share one failure contract: a nonzero exit rejects with the classified adb
32+
// error unless `allowFailure` is set, so consumers cannot observe divergent
33+
// semantics between an installed transport and local execution.
1934
export async function runAndroidHostAdb(
2035
args: string[],
2136
options?: ExecOptions,
2237
): Promise<ExecResult> {
2338
const transport = androidAdbHostTransportScope.getStore();
24-
if (!transport) return await runCmd('adb', args, options);
25-
return coerceExecResult(await transport(args, options));
39+
const result = transport
40+
? coerceExecResult(await transport(args, options))
41+
: await runCmd('adb', args, options);
42+
if (!options?.allowFailure && result.exitCode !== 0) {
43+
throw androidAdbResultError(
44+
`adb ${args.join(' ')} exited with code ${result.exitCode}`,
45+
result,
46+
);
47+
}
48+
return result;
2649
}
2750

2851
export async function withAndroidHostAdbTransport<T>(

src/sdk/limrun-runtime-dependencies.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,29 @@ test('Limrun appstate forwards an in-flight abort through the provider ADB execu
136136
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
137137
assert.equal(observedSignal, controller.signal);
138138
});
139+
140+
test('host.runAdb keeps its exported shape and routes through the host transport', async () => {
141+
const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts');
142+
const { withAndroidHostAdbTransport } = await import(
143+
'../platforms/android/adb-host-transport.ts'
144+
);
145+
const dependencies = createLimrunRuntimeDependencies();
146+
const seen: Array<{ args: string[]; options?: Record<string, unknown> }> = [];
147+
148+
const result = await withAndroidHostAdbTransport(
149+
async (args, options) => {
150+
seen.push({ args, ...(options ? { options } : {}) });
151+
return { stdout: 'ok', stderr: '', exitCode: 0 };
152+
},
153+
async () =>
154+
await dependencies.host.runAdb(['disconnect', 'emulator-5554'], {
155+
allowFailure: true,
156+
timeoutMs: 10_000,
157+
}),
158+
);
159+
160+
assert.deepEqual(result, { stdout: 'ok', stderr: '', exitCode: 0 });
161+
assert.deepEqual(seen, [
162+
{ args: ['disconnect', 'emulator-5554'], options: { allowFailure: true, timeoutMs: 10_000 } },
163+
]);
164+
});

0 commit comments

Comments
 (0)