fix: centralize Android adb execution (#478)

This commit is contained in:
Michał Pierzchała
2026-04-29 18:25:21 -04:00
committed by GitHub
parent 0c7e48d7d5
commit 72ba612e83
20 changed files with 619 additions and 97 deletions
+1
View File
@@ -9,6 +9,7 @@
"src/remote-config.ts",
"src/install-source.ts",
"src/android-apps.ts",
"src/android-adb.ts",
"src/android-snapshot-helper.ts",
"src/contracts.ts",
"src/selectors.ts",
+4
View File
@@ -49,6 +49,10 @@
"import": "./dist/src/android-apps.js",
"types": "./dist/src/android-apps.d.ts"
},
"./android-adb": {
"import": "./dist/src/android-adb.js",
"types": "./dist/src/android-adb.d.ts"
},
"./android-snapshot-helper": {
"import": "./dist/src/android-snapshot-helper.js",
"types": "./dist/src/android-snapshot-helper.d.ts"
+1
View File
@@ -24,6 +24,7 @@ export default defineConfig({
'remote-config': 'src/remote-config.ts',
'install-source': 'src/install-source.ts',
'android-apps': 'src/android-apps.ts',
'android-adb': 'src/android-adb.ts',
'android-snapshot-helper': 'src/android-snapshot-helper.ts',
contracts: 'src/contracts.ts',
selectors: 'src/selectors.ts',
+11
View File
@@ -0,0 +1,11 @@
export {
createDeviceAdbExecutor,
resolveAndroidAdbExecutor,
spawnAndroidAdbBySerial,
withAndroidAdbProvider,
type AndroidAdbExecutor,
type AndroidAdbExecutorOptions,
type AndroidAdbExecutorResult,
type AndroidAdbProvider,
type AndroidAdbSpawner,
} from './platforms/android/adb-executor.ts';
+9 -1
View File
@@ -201,6 +201,7 @@ test('device login opens browser, stores CLI session, and returns agent token',
const opened: string[] = [];
let stderr = '';
const requests: string[] = [];
const bodies: unknown[] = [];
const login = await loginWithDeviceAuth({
stateDir: tempRoot,
@@ -218,8 +219,9 @@ test('device login opens browser, stores CLI session, and returns agent token',
openBrowser: async (url) => {
opened.push(url);
},
fetch: async (url) => {
fetch: async (url, init) => {
requests.push(String(url));
bodies.push(JSON.parse(String(init?.body)));
if (String(url).endsWith('/api/control-plane/device-auth/start')) {
return jsonResponse({
deviceCode: 'device-secret',
@@ -255,6 +257,12 @@ test('device login opens browser, stores CLI session, and returns agent token',
'https://cloud.example/api/control-plane/device-auth/start',
'https://cloud.example/api/control-plane/device-auth/poll',
]);
assert.deepEqual(bodies[0], {
client: 'agent-device',
tenant: 'acme',
runId: 'run-123',
daemonBaseUrl: 'https://daemon.example',
});
assert.equal(readCliSession({ stateDir: tempRoot })?.refreshCredential, 'adc_refresh_login');
if (process.platform !== 'win32') {
const mode = fs.statSync(resolveCliSessionPath(tempRoot)).mode & 0o777;
@@ -0,0 +1,64 @@
import { expect, test, vi } from 'vitest';
vi.mock('../../core/dispatch.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../core/dispatch.ts')>();
return {
...actual,
dispatchCommand: vi.fn(),
resolveTargetDevice: vi.fn(),
};
});
vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
import { runCmd } from '../../utils/exec.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { createRequestHandler } from '../request-router.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
const androidDevice: DeviceInfo = {
platform: 'android',
id: 'remote-android-1',
name: 'Remote Android',
kind: 'device',
target: 'mobile',
booted: true,
};
test('router scopes first Android open request through injected adb provider', async () => {
vi.mocked(resolveTargetDevice).mockResolvedValue(androidDevice);
vi.mocked(dispatchCommand).mockImplementationOnce(async (device) => {
await runCmd('adb', ['-s', device.id, 'shell', 'am', 'start', 'com.example.app']);
return {};
});
const adbCalls: string[][] = [];
const providerCalls: Array<{ device: DeviceInfo; hasSession: boolean }> = [];
const handler = createRequestHandler({
logPath: '/tmp/daemon.log',
token: 'token',
sessionStore: makeSessionStore('agent-device-router-open-adb-provider-'),
leaseRegistry: new LeaseRegistry(),
androidAdbProvider: ({ device, session }) => {
providerCalls.push({ device, hasSession: Boolean(session) });
return async (args) => {
adbCalls.push(args);
return { stdout: '', stderr: '', exitCode: 0 };
};
},
trackDownloadableArtifact: () => 'artifact-id',
});
const response = await handler({
token: 'token',
session: 'default',
command: 'open',
positionals: ['com.example.app'],
flags: { platform: 'android' },
});
expect(response.ok).toBe(true);
expect(providerCalls).toEqual([{ device: androidDevice, hasSession: false }]);
expect(adbCalls).toContainEqual(['shell', 'am', 'start', 'com.example.app']);
});
@@ -0,0 +1,124 @@
import { expect, test } from 'vitest';
import { createRequestHandler } from '../request-router.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { SessionStore } from '../session-store.ts';
import { AppError } from '../../utils/errors.ts';
import type {
AndroidAdbExecutor,
AndroidAdbProvider,
} from '../../platforms/android/adb-executor.ts';
function makeAndroidSessionStore(name: string): SessionStore {
const sessionStore = new SessionStore(`/tmp/${name}`);
sessionStore.set('default', {
name: 'default',
createdAt: Date.now(),
device: {
platform: 'android',
id: 'remote-android-1',
name: 'Remote Android',
kind: 'device',
booted: true,
},
appBundleId: 'com.example.app',
actions: [],
});
return sessionStore;
}
function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => AndroidAdbProvider) {
return createRequestHandler({
logPath: '/tmp/daemon.log',
token: 'token',
sessionStore,
leaseRegistry: new LeaseRegistry(),
androidAdbProvider,
trackDownloadableArtifact: () => 'artifact-id',
});
}
test('request handler routes Android perf through injected adb executor', async () => {
const sessionStore = makeAndroidSessionStore('agent-device-request-router-perf-test');
const adbCalls: string[][] = [];
const adb: AndroidAdbExecutor = async (args) => {
adbCalls.push(args);
if (args.includes('meminfo')) {
return { stdout: 'TOTAL PSS: 100 TOTAL RSS: 200', stderr: '', exitCode: 0 };
}
if (args.includes('cpuinfo')) {
return {
stdout: '3.0% 1234/com.example.app: 2.0% user + 1.0% kernel',
stderr: '',
exitCode: 0,
};
}
return {
stdout: ['Total frames rendered: 4', 'Janky frames: 1 (25.00%)'].join('\n'),
stderr: '',
exitCode: 0,
};
};
const handler = makeHandler(sessionStore, () => ({ exec: adb }));
const response = await handler({
token: 'token',
session: 'default',
command: 'perf',
positionals: [],
flags: {},
});
expect(response.ok).toBe(true);
if (!response.ok) throw new Error('Expected perf response to succeed');
expect((response.data?.metrics as Record<string, any>)?.fps?.droppedFramePercent).toBe(25);
expect(adbCalls).toContainEqual(['shell', 'dumpsys', 'gfxinfo', 'com.example.app', 'reset']);
});
test('request handler reports injected Android adb failures per perf metric', async () => {
const sessionStore = makeAndroidSessionStore('agent-device-request-router-perf-unavailable-test');
const adb: AndroidAdbExecutor = async () => {
throw new AppError('COMMAND_FAILED', 'Remote Android ADB executor is unavailable');
};
const handler = makeHandler(sessionStore, () => ({ exec: adb }));
const response = await handler({
token: 'token',
session: 'default',
command: 'perf',
positionals: [],
flags: {},
});
expect(response.ok).toBe(true);
if (!response.ok) throw new Error('Expected perf response to succeed');
const metrics = response.data?.metrics as Record<string, any>;
for (const metricName of ['memory', 'cpu', 'fps']) {
const metric = metrics[metricName];
expect(metric.available).toBe(false);
expect(metric.reason).toBe('Remote Android ADB executor is unavailable');
expect(metric.error.details.metric).toBe(metricName);
}
});
test('request handler scopes generic Android commands through injected adb provider', async () => {
const sessionStore = makeAndroidSessionStore('agent-device-request-router-adb-provider-test');
const adbCalls: string[][] = [];
const adbProvider: AndroidAdbProvider = {
exec: async (args) => {
adbCalls.push(args);
return { stdout: '', stderr: '', exitCode: 0 };
},
};
const handler = makeHandler(sessionStore, () => adbProvider);
const response = await handler({
token: 'token',
session: 'default',
command: 'press',
positionals: ['10', '20'],
flags: {},
});
expect(response.ok).toBe(true);
expect(adbCalls).toContainEqual(['shell', 'input', 'tap', '10', '20']);
});
+3 -3
View File
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { spawnAndroidAdbBySerial } from '../platforms/android/adb-executor.ts';
import { AppError } from '../utils/errors.ts';
import { runCmd } from '../utils/exec.ts';
import {
@@ -70,7 +70,7 @@ export async function startAndroidAppLog(
): Promise<AppLogResult> {
let state: AppLogState = 'recovering';
let stopped = false;
let activeChild: ReturnType<typeof spawn> | undefined;
let activeChild: ReturnType<typeof spawnAndroidAdbBySerial> | undefined;
let activeWait: ReturnType<typeof attachChildToStream> | undefined;
const wait = (async () => {
@@ -82,7 +82,7 @@ export async function startAndroidAppLog(
await sleep(1_000);
continue;
}
const child = spawn('adb', ['-s', deviceId, 'logcat', '-v', 'time', '--pid', pid], {
const child = spawnAndroidAdbBySerial(deviceId, ['logcat', '-v', 'time', '--pid', pid], {
stdio: ['ignore', 'pipe', 'pipe'],
});
activeChild = child;
+7 -2
View File
@@ -1,5 +1,6 @@
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import { normalizeError } from '../../utils/errors.ts';
import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
import { SessionStore } from '../session-store.ts';
import {
@@ -27,6 +28,7 @@ type ObservabilityParams = {
req: DaemonRequest;
sessionName: string;
sessionStore: SessionStore;
androidAdbExecutor?: AndroidAdbExecutor;
};
function resolveSessionLogBackendLabel(
@@ -67,14 +69,17 @@ export async function handleSessionObservabilityCommands(
// ---------------------------------------------------------------------------
async function handlePerfCommand(params: ObservabilityParams): Promise<DaemonResponse> {
const { sessionName, sessionStore } = params;
const { sessionName, sessionStore, androidAdbExecutor } = params;
const session = sessionStore.get(sessionName);
if (!session) {
return errorResponse('SESSION_NOT_FOUND', 'perf requires an active session. Run open first.');
}
try {
return { ok: true, data: await buildPerfResponseData(session) };
return {
ok: true,
data: await buildPerfResponseData(session, { androidAdb: androidAdbExecutor }),
};
} catch (error) {
return { ok: false, error: normalizeError(error) };
}
+16 -6
View File
@@ -1,5 +1,6 @@
import type { SessionAction, SessionState } from '../types.ts';
import { normalizeError } from '../../utils/errors.ts';
import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts';
import {
ANDROID_CPU_SAMPLE_DESCRIPTION,
ANDROID_CPU_SAMPLE_METHOD,
@@ -32,6 +33,9 @@ type PerfResponseData = {
metrics: Record<string, unknown>;
sampling: Record<string, unknown>;
};
type BuildPerfResponseOptions = {
androidAdb?: AndroidAdbExecutor;
};
const RELATED_PERF_ACTION_LIMIT = 12;
@@ -70,6 +74,7 @@ function readStartupPerfSamples(actions: SessionAction[]): StartupPerfSample[] {
export async function buildPerfResponseData(
session: SessionState,
options: BuildPerfResponseOptions = {},
): Promise<Record<string, unknown>> {
const response = buildBasePerfResponse(session);
@@ -83,7 +88,7 @@ export async function buildPerfResponseData(
}
if (session.device.platform === 'android') {
await applyAndroidPerfMetrics(response, session);
await applyAndroidPerfMetrics(response, session, options);
return response;
}
@@ -149,8 +154,9 @@ function applyMissingAppPerfMetrics(response: PerfResponseData, session: Session
async function applyAndroidPerfMetrics(
response: PerfResponseData,
session: SessionState,
options: BuildPerfResponseOptions,
): Promise<void> {
const results = await sampleAndroidPerfResults(session);
const results = await sampleAndroidPerfResults(session, options);
response.metrics.memory = buildMetricResult(results.memory);
response.metrics.cpu = buildMetricResult(results.cpu);
response.metrics.fps = enrichFrameMetricWithSessionContext(
@@ -210,16 +216,20 @@ function buildPlatformSamplingMetadata(session: SessionState): Record<string, un
return buildAppleSamplingMetadata(session.device);
}
async function sampleAndroidPerfResults(session: SessionState): Promise<{
async function sampleAndroidPerfResults(
session: SessionState,
options: BuildPerfResponseOptions,
): Promise<{
memory: SettledMetricResult;
cpu: SettledMetricResult;
fps: SettledMetricResult;
}> {
const appBundleId = session.appBundleId as string;
const androidPerfOptions = { adb: options.androidAdb };
const [memory, cpu, fps] = await Promise.allSettled([
sampleAndroidMemoryPerf(session.device, appBundleId),
sampleAndroidCpuPerf(session.device, appBundleId),
sampleAndroidFramePerf(session.device, appBundleId),
sampleAndroidMemoryPerf(session.device, appBundleId, androidPerfOptions),
sampleAndroidCpuPerf(session.device, appBundleId, androidPerfOptions),
sampleAndroidFramePerf(session.device, appBundleId, androidPerfOptions),
]);
return { memory, cpu, fps };
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { dispatchCommand } from '../../core/dispatch.ts';
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import { resolvePayloadInput } from '../../utils/payload-input.ts';
import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { normalizePlatformSelector } from '../../utils/device.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
@@ -139,8 +140,9 @@ export async function handleSessionCommands(params: {
logPath: string;
sessionStore: SessionStore;
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
androidAdbExecutor?: AndroidAdbExecutor;
}): Promise<DaemonResponse | null> {
const { req, sessionName, logPath, sessionStore, invoke } = params;
const { req, sessionName, logPath, sessionStore, invoke, androidAdbExecutor } = params;
if (INVENTORY_COMMANDS.has(req.command)) {
return await handleSessionInventoryCommands({
@@ -203,6 +205,7 @@ export async function handleSessionCommands(params: {
req,
sessionName,
sessionStore,
androidAdbExecutor,
});
}
+106 -35
View File
@@ -1,5 +1,10 @@
import path from 'node:path';
import type { CommandFlags } from '../core/dispatch.ts';
import {
withAndroidAdbProvider,
type AndroidAdbExecutor,
type AndroidAdbProvider,
} from '../platforms/android/adb-executor.ts';
import {
dispatchCommand,
resolveTargetDevice,
@@ -7,6 +12,7 @@ import {
} from '../core/dispatch.ts';
import { isCommandSupportedOnDevice } from '../core/capabilities.ts';
import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts';
import type { DeviceInfo } from '../utils/device.ts';
import type {
DaemonArtifact,
DaemonRequest,
@@ -80,6 +86,12 @@ const leaseAdmissionExemptCommands = new Set([
const sessionExecutionExemptCommands = new Set(leaseAdmissionExemptCommands);
const sessionExecutionLocks = new Map<string, Promise<unknown>>();
type AndroidAdbProviderResolver = (params: {
req: DaemonRequest;
device: DeviceInfo;
session?: SessionState;
}) => AndroidAdbProvider | AndroidAdbExecutor | undefined;
// ---------------------------------------------------------------------------
// Request preparation helpers
// ---------------------------------------------------------------------------
@@ -290,6 +302,37 @@ async function resolveExecutionLockKey(
return `session:${sessionName}`;
}
async function resolveAndroidAdbProviderDevice(
req: DaemonRequest,
existingSession: SessionState | undefined,
): Promise<DeviceInfo | undefined> {
if (existingSession) {
return existingSession.device.platform === 'android' ? existingSession.device : undefined;
}
if (req.command !== 'open' && !hasExplicitDeviceSelector(req.flags)) {
return undefined;
}
const device = await resolveTargetDevice(req.flags ?? {});
return device.platform === 'android' ? device : undefined;
}
async function resolveScopedAndroidAdbProvider(params: {
req: DaemonRequest;
existingSession: SessionState | undefined;
androidAdbProvider?: AndroidAdbProviderResolver;
}): Promise<{
provider?: AndroidAdbProvider | AndroidAdbExecutor;
executor?: AndroidAdbExecutor;
}> {
const { req, existingSession, androidAdbProvider } = params;
if (!androidAdbProvider) return {};
const device = await resolveAndroidAdbProviderDevice(req, existingSession);
if (!device) return {};
const provider = androidAdbProvider({ req, device, session: existingSession });
const executor = typeof provider === 'function' ? provider : provider?.exec;
return { provider, executor };
}
// ---------------------------------------------------------------------------
// Specialized handler chain
// ---------------------------------------------------------------------------
@@ -301,14 +344,23 @@ async function runHandlerChain(params: {
sessionStore: SessionStore;
leaseRegistry: LeaseRegistry;
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
androidAdbExecutor?: AndroidAdbExecutor;
contextFromFlags: (
flags: CommandFlags | undefined,
appBundleId?: string,
traceLogPath?: string,
) => DaemonCommandContext;
}): Promise<DaemonResponse | null> {
const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke, contextFromFlags } =
params;
const {
req,
sessionName,
logPath,
sessionStore,
leaseRegistry,
invoke,
androidAdbExecutor,
contextFromFlags,
} = params;
const leaseResponse = await handleLeaseCommands({ req, leaseRegistry });
if (leaseResponse) return leaseResponse;
@@ -319,6 +371,7 @@ async function runHandlerChain(params: {
logPath,
sessionStore,
invoke,
androidAdbExecutor,
});
if (sessionResponse) return sessionResponse;
@@ -549,6 +602,7 @@ export type RequestRouterDeps = {
token: string;
sessionStore: SessionStore;
leaseRegistry: LeaseRegistry;
androidAdbProvider?: AndroidAdbProviderResolver;
trackDownloadableArtifact: (opts: {
artifactPath: string;
tenantId?: string;
@@ -559,7 +613,14 @@ export type RequestRouterDeps = {
export function createRequestHandler(
deps: RequestRouterDeps,
): (req: DaemonRequest) => Promise<DaemonResponse> {
const { logPath, token, sessionStore, leaseRegistry, trackDownloadableArtifact } = deps;
const {
logPath,
token,
sessionStore,
leaseRegistry,
androidAdbProvider,
trackDownloadableArtifact,
} = deps;
async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
const debug = Boolean(req.meta?.debug || req.flags?.verbose);
@@ -641,43 +702,53 @@ export function createRequestHandler(
assertSessionSelectorMatches(existingSession, lockedReq.flags);
}
// Phase 1: Try specialized handler chain
const handlerResponse = await runHandlerChain({
const requestAdb = await resolveScopedAndroidAdbProvider({
req: lockedReq,
sessionName,
logPath,
sessionStore,
leaseRegistry,
invoke: handleRequest,
contextFromFlags: (flags, appBundleId, traceLogPath) =>
({
...contextFromFlags(logPath, flags, appBundleId, traceLogPath),
surface: sessionStore.get(sessionName)?.surface,
}) satisfies DaemonCommandContext,
existingSession,
androidAdbProvider,
});
if (handlerResponse) return finalize(handlerResponse);
// Phase 2: Require active session for generic dispatch
const session = sessionStore.get(sessionName);
if (!session) {
return finalize({
ok: false,
error: {
code: 'SESSION_NOT_FOUND',
message: 'No active session. Run open first.',
},
return await withAndroidAdbProvider(requestAdb.provider, async () => {
// The ADB provider is scoped to this single locked request; handlers may re-read
// the session state, but all device-scoped adb calls in this request share it.
// Phase 1: Try specialized handler chain
const handlerResponse = await runHandlerChain({
req: lockedReq,
sessionName,
logPath,
sessionStore,
leaseRegistry,
invoke: handleRequest,
androidAdbExecutor: requestAdb.executor,
contextFromFlags: (flags, appBundleId, traceLogPath) =>
({
...contextFromFlags(logPath, flags, appBundleId, traceLogPath),
surface: sessionStore.get(sessionName)?.surface,
}) satisfies DaemonCommandContext,
});
}
if (handlerResponse) return finalize(handlerResponse);
// Phase 3: Dispatch command directly to device
const dispatchResponse = await dispatchGenericCommand({
req: lockedReq,
session,
sessionName,
logPath,
sessionStore,
// Phase 2: Require active session for generic dispatch
const session = sessionStore.get(sessionName);
if (!session) {
return finalize({
ok: false,
error: {
code: 'SESSION_NOT_FOUND',
message: 'No active session. Run open first.',
},
});
}
// Phase 3: Dispatch command directly to device
const dispatchResponse = await dispatchGenericCommand({
req: lockedReq,
session,
sessionName,
logPath,
sessionStore,
});
return finalize(dispatchResponse);
});
return finalize(dispatchResponse);
};
if (!executionLockKey) {
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { test, vi } from 'vitest';
vi.mock('../../../utils/exec.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../utils/exec.ts')>();
return {
...actual,
runCmd: vi.fn(async () => ({ stdout: 'ok', stderr: '', exitCode: 0 })),
};
});
import { createDeviceAdbExecutor, withAndroidAdbProvider } from '../adb-executor.ts';
import { runCmd } from '../../../utils/exec.ts';
const mockRunCmd = vi.mocked(runCmd);
test('createDeviceAdbExecutor routes local commands through adb with the device serial', async () => {
const adb = createDeviceAdbExecutor({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
});
const result = await adb(['shell', 'getprop', 'sys.boot_completed'], { timeoutMs: 1000 });
assert.deepEqual(result, { stdout: 'ok', stderr: '', exitCode: 0 });
assert.deepEqual(mockRunCmd.mock.calls, [
['adb', ['-s', 'emulator-5554', 'shell', 'getprop', 'sys.boot_completed'], { timeoutMs: 1000 }],
]);
});
test('createDeviceAdbExecutor remains a local adb executor inside provider scopes', async () => {
mockRunCmd.mockClear();
const providerCalls: string[][] = [];
const adb = createDeviceAdbExecutor({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
});
const result = await withAndroidAdbProvider(
async (args) => {
providerCalls.push(args);
return { stdout: 'provider', stderr: '', exitCode: 0 };
},
async () => await adb(['shell', 'echo', 'local']),
);
assert.equal(result.stdout, 'ok');
assert.deepEqual(providerCalls, []);
assert.deepEqual(mockRunCmd.mock.calls, [
['adb', ['-s', 'emulator-5554', 'shell', 'echo', 'local'], undefined],
]);
});
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import type { ChildProcess } from 'node:child_process';
import { test } from 'vitest';
import { runCmd } from '../../../utils/exec.ts';
import { spawnAndroidAdbBySerial, withAndroidAdbProvider } from '../adb-executor.ts';
test('withAndroidAdbProvider intercepts scoped adb commands with a device serial', async () => {
const calls: string[][] = [];
const result = await withAndroidAdbProvider(
async (args, options) => {
calls.push(args);
return {
stdout: options?.allowFailure ? 'allowed' : 'ok',
stderr: '',
exitCode: 0,
};
},
async () =>
await runCmd('adb', ['-s', 'emulator-5554', 'shell', 'echo', 'ok'], {
allowFailure: true,
}),
);
assert.equal(result.stdout, 'allowed');
assert.deepEqual(calls, [['shell', 'echo', 'ok']]);
});
test('spawnAndroidAdbBySerial uses the scoped provider spawner', async () => {
const child = { pid: 123 } as ChildProcess;
const calls: string[][] = [];
const result = await withAndroidAdbProvider(
{
exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
spawn: (args) => {
calls.push(args);
return child;
},
},
async () => spawnAndroidAdbBySerial('emulator-5554', ['logcat', '-v', 'time']),
);
assert.equal(result, child);
assert.deepEqual(calls, [['logcat', '-v', 'time']]);
});
+107
View File
@@ -0,0 +1,107 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
import type { DeviceInfo } from '../../utils/device.ts';
import {
runCmd,
withCommandExecutorOverride,
withoutCommandExecutorOverride,
type CommandExecutorOverride,
type ExecOptions,
type ExecResult,
} from '../../utils/exec.ts';
export type AndroidAdbExecutorOptions = Pick<
ExecOptions,
'allowFailure' | 'timeoutMs' | 'binaryStdout' | 'stdin' | 'signal'
>;
export type AndroidAdbExecutorResult = Pick<
ExecResult,
'exitCode' | 'stdout' | 'stderr' | 'stdoutBuffer'
>;
/**
* Runs device-scoped adb arguments after the device serial has already been selected.
* Implementations must be safe to call concurrently for one request.
*/
export type AndroidAdbExecutor = (
args: string[],
options?: AndroidAdbExecutorOptions,
) => Promise<AndroidAdbExecutorResult>;
export type AndroidAdbSpawner = (args: string[], options?: SpawnOptions) => ChildProcess;
export type AndroidAdbProvider = {
exec: AndroidAdbExecutor;
spawn?: AndroidAdbSpawner;
};
const androidAdbProviderScope = new AsyncLocalStorage<AndroidAdbProvider>();
export function createDeviceAdbExecutor(device: DeviceInfo): AndroidAdbExecutor {
return createSerialAdbExecutor(device.id);
}
function createSerialAdbExecutor(serial: string): AndroidAdbExecutor {
return async (args, options) =>
// Local adb execution must escape any active provider scope to avoid routing
// tunnel-backed providers back into themselves when they shell out to adb.
await withoutCommandExecutorOverride(
async () => await runCmd('adb', ['-s', serial, ...args], options),
);
}
function createSerialAdbSpawner(serial: string): AndroidAdbSpawner {
return (args, options) => spawn('adb', ['-s', serial, ...args], options ?? {});
}
export function resolveAndroidAdbExecutor(
device: DeviceInfo,
executor?: AndroidAdbExecutor,
): AndroidAdbExecutor {
return executor ?? androidAdbProviderScope.getStore()?.exec ?? createDeviceAdbExecutor(device);
}
export function spawnAndroidAdbBySerial(
serial: string,
args: string[],
options?: SpawnOptions,
): ChildProcess {
return resolveAndroidSerialAdbSpawner(serial)(args, options);
}
export async function withAndroidAdbProvider<T>(
provider: AndroidAdbProvider | AndroidAdbExecutor | undefined,
fn: () => Promise<T>,
): Promise<T> {
if (!provider) return await fn();
const normalized = typeof provider === 'function' ? { exec: provider } : provider;
const override = createAndroidCommandExecutorOverride(normalized);
return await androidAdbProviderScope.run(
normalized,
async () => await withCommandExecutorOverride(override, fn),
);
}
function createAndroidCommandExecutorOverride(
provider: AndroidAdbProvider,
): CommandExecutorOverride {
return (cmd, args, options) => {
if (cmd !== 'adb') return undefined;
const providerArgs = stripAdbSerialArgs(args);
if (!providerArgs) return undefined;
return withoutCommandExecutorOverride(async () => await provider.exec(providerArgs, options));
};
}
function stripAdbSerialArgs(args: string[]): string[] | undefined {
// The provider scope only owns normalized device-scoped adb calls produced by
// this repo's adbArgs helpers: adb -s <serial> <command...>. Global commands
// such as adb devices/version and host-preconfigured invocations stay local.
if (args[0] !== '-s' || !args[1]) return undefined;
return args.slice(2);
}
function resolveAndroidSerialAdbSpawner(serial: string): AndroidAdbSpawner {
return androidAdbProviderScope.getStore()?.spawn ?? createSerialAdbSpawner(serial);
}
+15 -17
View File
@@ -1,7 +1,6 @@
import type { DeviceInfo } from '../../utils/device.ts';
import { AppError } from '../../utils/errors.ts';
import { runCmd } from '../../utils/exec.ts';
import { adbArgs } from './adb.ts';
import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts';
import { parseAndroidFramePerfSample, type AndroidFramePerfSample } from './perf-frame-parser.ts';
export {
@@ -15,22 +14,26 @@ export {
const ANDROID_FRAME_PERF_TIMEOUT_MS = 15_000;
const ANDROID_FRAME_RESET_TIMEOUT_MS = 3_000;
export type AndroidFramePerfOptions = {
adb?: AndroidAdbExecutor;
};
export async function sampleAndroidFramePerf(
device: DeviceInfo,
packageName: string,
options: AndroidFramePerfOptions = {},
): Promise<AndroidFramePerfSample> {
const adb = resolveAndroidAdbExecutor(device, options.adb);
try {
const result = await runCmd(
'adb',
adbArgs(device, ['shell', 'dumpsys', 'gfxinfo', packageName, 'framestats']),
{ timeoutMs: ANDROID_FRAME_PERF_TIMEOUT_MS },
);
const result = await adb(['shell', 'dumpsys', 'gfxinfo', packageName, 'framestats'], {
timeoutMs: ANDROID_FRAME_PERF_TIMEOUT_MS,
});
const sample = parseAndroidFramePerfSample(
result.stdout,
packageName,
new Date().toISOString(),
);
await resetAndroidFramePerfStats(device, packageName);
await resetAndroidFramePerfStats(device, packageName, options);
return sample;
} catch (error) {
throw annotateAndroidFramePerfSamplingError(packageName, error);
@@ -40,9 +43,11 @@ export async function sampleAndroidFramePerf(
export async function resetAndroidFramePerfStats(
device: DeviceInfo,
packageName: string,
options: AndroidFramePerfOptions = {},
): Promise<void> {
const adb = resolveAndroidAdbExecutor(device, options.adb);
try {
await runCmd('adb', adbArgs(device, ['shell', 'dumpsys', 'gfxinfo', packageName, 'reset']), {
await adb(['shell', 'dumpsys', 'gfxinfo', packageName, 'reset'], {
allowFailure: true,
timeoutMs: ANDROID_FRAME_RESET_TIMEOUT_MS,
});
@@ -52,10 +57,7 @@ export async function resetAndroidFramePerfStats(
}
function annotateAndroidFramePerfSamplingError(packageName: string, error: unknown): AppError {
if (
error instanceof AppError &&
(error.code === 'TOOL_MISSING' || error.code === 'COMMAND_FAILED')
) {
if (error instanceof AppError) {
return new AppError(
error.code,
error.message,
@@ -68,10 +70,6 @@ function annotateAndroidFramePerfSamplingError(packageName: string, error: unkno
);
}
if (error instanceof AppError) {
return error;
}
return new AppError(
'COMMAND_FAILED',
`Failed to sample Android fps for ${packageName}`,
+14 -16
View File
@@ -1,7 +1,6 @@
import type { DeviceInfo } from '../../utils/device.ts';
import { AppError } from '../../utils/errors.ts';
import { runCmd } from '../../utils/exec.ts';
import { adbArgs } from './adb.ts';
import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts';
import { roundPercent } from '../perf-utils.ts';
export {
ANDROID_FRAME_SAMPLE_DESCRIPTION,
@@ -22,6 +21,10 @@ export const ANDROID_MEMORY_SAMPLE_DESCRIPTION =
const ANDROID_PERF_TIMEOUT_MS = 15_000;
export type AndroidPerfOptions = {
adb?: AndroidAdbExecutor;
};
export type AndroidCpuPerfSample = {
usagePercent: number;
measuredAt: string;
@@ -39,9 +42,11 @@ export type AndroidMemoryPerfSample = {
export async function sampleAndroidCpuPerf(
device: DeviceInfo,
packageName: string,
options: AndroidPerfOptions = {},
): Promise<AndroidCpuPerfSample> {
const adb = resolveAndroidAdbExecutor(device, options.adb);
try {
const result = await runCmd('adb', adbArgs(device, ['shell', 'dumpsys', 'cpuinfo']), {
const result = await adb(['shell', 'dumpsys', 'cpuinfo'], {
timeoutMs: ANDROID_PERF_TIMEOUT_MS,
});
return parseAndroidCpuInfoSample(result.stdout, packageName, new Date().toISOString());
@@ -53,13 +58,13 @@ export async function sampleAndroidCpuPerf(
export async function sampleAndroidMemoryPerf(
device: DeviceInfo,
packageName: string,
options: AndroidPerfOptions = {},
): Promise<AndroidMemoryPerfSample> {
const adb = resolveAndroidAdbExecutor(device, options.adb);
try {
const result = await runCmd(
'adb',
adbArgs(device, ['shell', 'dumpsys', 'meminfo', packageName]),
{ timeoutMs: ANDROID_PERF_TIMEOUT_MS },
);
const result = await adb(['shell', 'dumpsys', 'meminfo', packageName], {
timeoutMs: ANDROID_PERF_TIMEOUT_MS,
});
return parseAndroidMemInfoSample(result.stdout, packageName, new Date().toISOString());
} catch (error) {
throw annotateAndroidPerfSamplingError('memory', packageName, error);
@@ -141,10 +146,7 @@ function annotateAndroidPerfSamplingError(
packageName: string,
error: unknown,
): AppError {
if (
error instanceof AppError &&
(error.code === 'TOOL_MISSING' || error.code === 'COMMAND_FAILED')
) {
if (error instanceof AppError) {
return new AppError(
error.code,
error.message,
@@ -157,10 +159,6 @@ function annotateAndroidPerfSamplingError(
);
}
if (error instanceof AppError) {
return error;
}
return new AppError(
'COMMAND_FAILED',
`Failed to sample Android ${metric} for ${packageName}`,
+2 -11
View File
@@ -1,4 +1,5 @@
import type { RawSnapshotNode } from '../../utils/snapshot.ts';
import type { AndroidAdbExecutor } from './adb-executor.ts';
import type { AndroidSnapshotAnalysis } from './ui-hierarchy.ts';
import type { AndroidSnapshotBackendMetadata } from './snapshot-types.ts';
@@ -11,17 +12,7 @@ export const ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT = 'uiautomator-xml';
export const ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS = 500;
export const ANDROID_SNAPSHOT_HELPER_COMMAND_OVERHEAD_MS = 5_000;
export type AndroidAdbExecutor = (
args: string[],
options?: {
allowFailure?: boolean;
timeoutMs?: number;
},
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
export type { AndroidAdbExecutor } from './adb-executor.ts';
export type AndroidSnapshotHelperManifest = {
name: 'android-snapshot-helper';
+1 -4
View File
@@ -22,6 +22,7 @@ import {
type AndroidUiHierarchy,
} from './ui-hierarchy.ts';
import { adbArgs } from './adb.ts';
import { createDeviceAdbExecutor } from './adb-executor.ts';
import { deriveAndroidScrollableContentHints } from './scroll-hints.ts';
import {
captureAndroidSnapshotWithHelper,
@@ -188,10 +189,6 @@ async function captureStockUiHierarchy(
};
}
function createDeviceAdbExecutor(device: DeviceInfo): AndroidAdbExecutor {
return async (args, options) => await runCmd('adb', adbArgs(device, args), options);
}
function getAndroidSnapshotHelperDeviceKey(device: DeviceInfo): string {
// Emulator serials are port-based and can be reused after restart; capture failure invalidates
// this key before falling back so stale process-local entries self-heal on the next snapshot.
+26 -1
View File
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { constants } from 'node:fs';
import { access, stat } from 'node:fs/promises';
import path from 'node:path';
@@ -13,7 +14,7 @@ export type ExecResult = {
stdoutBuffer?: Buffer;
};
type ExecOptions = {
export type ExecOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
allowFailure?: boolean;
@@ -41,12 +42,34 @@ type ExecDetachedOptions = ExecOptions & {
const BARE_COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
const WINDOWS_PATH_EXTENSIONS = ['.com', '.exe', '.bat', '.cmd'];
export type CommandExecutorOverride = (
cmd: string,
args: string[],
options: ExecOptions,
) => Promise<ExecResult> | undefined;
const commandExecutorOverrideScope = new AsyncLocalStorage<CommandExecutorOverride | undefined>();
export async function withCommandExecutorOverride<T>(
override: CommandExecutorOverride | undefined,
fn: () => Promise<T>,
): Promise<T> {
if (!override) return await fn();
return await commandExecutorOverrideScope.run(override, fn);
}
// Used by local executors to bypass the active override for intentional host command execution.
export async function withoutCommandExecutorOverride<T>(fn: () => Promise<T>): Promise<T> {
return await commandExecutorOverrideScope.run(undefined, fn);
}
export async function runCmd(
cmd: string,
args: string[],
options: ExecOptions = {},
): Promise<ExecResult> {
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
if (overrideResult) return await overrideResult;
return await runSpawnedCommand(cmd, args, options);
}
@@ -55,6 +78,8 @@ export async function runCmdStreaming(
args: string[],
options: ExecStreamOptions = {},
): Promise<ExecResult> {
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
if (overrideResult) return await overrideResult;
return await runSpawnedCommand(cmd, args, options);
}