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
1 change: 1 addition & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 11 additions & 0 deletions src/android-adb.ts
Original file line number Diff line number Diff line change
@@ -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';
10 changes: 9 additions & 1 deletion src/cli/__tests__/auth-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
124 changes: 124 additions & 0 deletions src/daemon/__tests__/request-router-android-perf.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
6 changes: 3 additions & 3 deletions src/daemon/app-log-android.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions src/daemon/handlers/session-observability.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -27,6 +28,7 @@ type ObservabilityParams = {
req: DaemonRequest;
sessionName: string;
sessionStore: SessionStore;
androidAdbExecutor?: AndroidAdbExecutor;
};

function resolveSessionLogBackendLabel(
Expand Down Expand Up @@ -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) };
}
Expand Down
22 changes: 16 additions & 6 deletions src/daemon/handlers/session-perf.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -32,6 +33,9 @@ type PerfResponseData = {
metrics: Record<string, unknown>;
sampling: Record<string, unknown>;
};
type BuildPerfResponseOptions = {
androidAdb?: AndroidAdbExecutor;
};

const RELATED_PERF_ACTION_LIMIT = 12;

Expand Down Expand Up @@ -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);

Expand All @@ -83,7 +88,7 @@ export async function buildPerfResponseData(
}

if (session.device.platform === 'android') {
await applyAndroidPerfMetrics(response, session);
await applyAndroidPerfMetrics(response, session, options);
return response;
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 };
}
Expand Down
Loading
Loading