From 74d4f0811947f8d436caf9e32b4a99e769616ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 25 Feb 2026 20:50:31 +0100 Subject: [PATCH 1/5] Fix Android boot completion and headless emulator launch --- README.md | 2 + skills/agent-device/SKILL.md | 2 + src/daemon/handlers/__tests__/session.test.ts | 125 +++++++- src/daemon/handlers/session.ts | 120 ++++++- .../android/__tests__/devices.test.ts | 182 +++++++++++ src/platforms/android/devices.ts | 302 ++++++++++++++++-- src/utils/__tests__/args.test.ts | 8 + src/utils/command-schema.ts | 10 +- website/docs/docs/commands.md | 3 + website/docs/docs/quick-start.md | 4 + 10 files changed, 732 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2f3db9c07..89390b6e1 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,8 @@ Boot diagnostics: - Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`. - Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors. - Use `agent-device boot --platform ios|android|apple` when starting a new session only if `open` cannot find/connect to an available target. +- Android emulator boot by AVD name (GUI): `agent-device boot --platform android --device Pixel_9_Pro_XL`. +- Android headless emulator boot: `agent-device boot --platform android --device Pixel_9_Pro_XL --headless`. - `--debug` captures retry telemetry in diagnostics logs. - Set `AGENT_DEVICE_RETRY_LOGS=1` to also print retry telemetry directly to stderr (ad-hoc troubleshooting). diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index 7b75ece9c..dc2219b32 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -99,6 +99,8 @@ agent-device session list ``` Use `boot` only as fallback when `open` cannot find/connect to a ready target. +For Android emulators by AVD name, use `boot --platform android --device `. +For Android emulators without GUI, add `--headless`. Use `--target mobile|tv` with `--platform` (required) to pick phone/tablet vs TV targets (AndroidTV/tvOS). TV quick reference: diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 432d16a28..5d0faaa37 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { handleSessionCommands } from '../session.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; +import { AppError } from '../../../utils/errors.ts'; function makeSessionStore(): SessionStore { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-handler-')); @@ -312,7 +313,7 @@ test('boot succeeds for supported device in session', async () => { }); assert.ok(response); assert.equal(response?.ok, true); - assert.equal(ensureCalls, 1); + assert.equal(ensureCalls, 0); if (response && response.ok) { assert.equal(response.data?.platform, 'android'); assert.equal(response.data?.booted, true); @@ -368,6 +369,128 @@ test('boot prefers explicit device selector over active session device', async ( } }); +test('boot --headless launches Android emulator when no running device matches', async () => { + const sessionStore = makeSessionStore(); + const ensured: string[] = []; + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', target: 'mobile', device: 'Pixel_9_Pro_XL', headless: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ensureReady: async (device) => { + ensured.push(device.id); + }, + resolveTargetDevice: async () => { + throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); + }, + ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }, + }); + + assert.ok(response); + assert.equal(response?.ok, true); + assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: true }]); + assert.deepEqual(ensured, ['emulator-5554']); + if (response && response.ok) { + assert.equal(response.data?.platform, 'android'); + assert.equal(response.data?.id, 'emulator-5554'); + assert.equal(response.data?.device, 'Pixel_9_Pro_XL'); + } +}); + +test('boot launches Android emulator with GUI when no running device matches', async () => { + const sessionStore = makeSessionStore(); + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', target: 'mobile', device: 'Pixel_9_Pro_XL' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ensureReady: async () => {}, + resolveTargetDevice: async () => { + throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); + }, + ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }, + }); + + assert.ok(response); + assert.equal(response?.ok, true); + assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); + if (response && response.ok) { + assert.equal(response.data?.platform, 'android'); + assert.equal(response.data?.id, 'emulator-5554'); + assert.equal(response.data?.device, 'Pixel_9_Pro_XL'); + } +}); + +test('boot --headless requires avd selector when device cannot be resolved', async () => { + const sessionStore = makeSessionStore(); + let bootCalled = false; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', target: 'mobile', serial: 'emulator-5554', headless: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ensureReady: async () => {}, + resolveTargetDevice: async () => { + throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); + }, + ensureAndroidEmulatorBoot: async () => { + bootCalled = true; + throw new Error('unexpected'); + }, + }); + + assert.ok(response); + assert.equal(response?.ok, false); + assert.equal(bootCalled, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /boot --headless requires --device /); + } +}); + test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 750a51b4f..ce69b2e4f 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -48,6 +48,12 @@ type ReinstallOps = { android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>; }; +type EnsureAndroidEmulatorBoot = (params: { + avdName: string; + serial?: string; + headless?: boolean; +}) => Promise; + const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "" ).'; const BATCH_PARENT_FLAG_KEYS: Array = ['platform', 'target', 'device', 'udid', 'serial', 'verbose', 'out']; @@ -226,6 +232,27 @@ async function resolveCommandDevice(params: { return device; } +function resolveAndroidEmulatorAvdName(params: { + flags: DaemonRequest['flags'] | undefined; + sessionDevice?: DeviceInfo; + resolvedDevice?: DeviceInfo; +}): string | undefined { + const explicit = params.flags?.device?.trim(); + if (explicit) return explicit; + if (params.resolvedDevice?.platform === 'android' && params.resolvedDevice.kind === 'emulator') { + return params.resolvedDevice.name; + } + if (params.sessionDevice?.platform === 'android' && params.sessionDevice.kind === 'emulator') { + return params.sessionDevice.name; + } + return undefined; +} + +const defaultEnsureAndroidEmulatorBoot: EnsureAndroidEmulatorBoot = async ({ avdName, serial, headless }) => { + const { ensureAndroidEmulatorBooted } = await import('../../platforms/android/devices.ts'); + return await ensureAndroidEmulatorBooted({ avdName, serial, headless }); +}; + const defaultReinstallOps: ReinstallOps = { ios: async (device, app, appPath) => { const { reinstallIosApp } = await import('../../platforms/ios/index.ts'); @@ -442,6 +469,7 @@ export async function handleSessionCommands(params: { start: typeof startAppLog; stop: typeof stopAppLog; }; + ensureAndroidEmulatorBoot?: EnsureAndroidEmulatorBoot; resolveAndroidPackageForOpen?: ( device: DeviceInfo, openTarget: string | undefined, @@ -462,6 +490,7 @@ export async function handleSessionCommands(params: { start: startAppLog, stop: stopAppLog, }, + ensureAndroidEmulatorBoot: ensureAndroidEmulatorBootOverride = defaultEnsureAndroidEmulatorBoot, resolveAndroidPackageForOpen: resolveAndroidPackageForOpenOverride = resolveAndroidPackageForOpen, } = params; const dispatch = dispatchOverride ?? dispatchCommand; @@ -555,13 +584,94 @@ export async function handleSessionCommands(params: { const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(command, session, flags); if (guard) return guard; - const device = await resolveCommandDevice({ - session, + const normalizedPlatform = normalizePlatformSelector(flags.platform) ?? session?.device.platform; + const targetsAndroid = normalizedPlatform === 'android'; + const wantsAndroidHeadless = flags.headless === true; + const shouldUseFastAndroidSelectorLookup = targetsAndroid && !flags.target && Boolean(flags.device || flags.serial); + const fallbackAvdName = resolveAndroidEmulatorAvdName({ flags, - ensureReadyFn: ensureReady, - resolveTargetDeviceFn: resolveDevice, - ensureReady: true, + sessionDevice: session?.device, }); + const canFallbackLaunchAndroidEmulator = targetsAndroid && Boolean(fallbackAvdName); + let device: DeviceInfo; + let launchedAndroidEmulator = false; + const fastLookupDevice = shouldUseFastAndroidSelectorLookup + ? await (async () => { + const { resolveAndroidBootSelectorDevice } = await import('../../platforms/android/devices.ts'); + return await resolveAndroidBootSelectorDevice({ + deviceName: flags.device, + serial: flags.serial, + }); + })() + : undefined; + try { + device = fastLookupDevice + ?? (await resolveCommandDevice({ + session, + flags, + ensureReadyFn: ensureReady, + resolveTargetDeviceFn: resolveDevice, + ensureReady: false, + })); + } catch (error) { + const appErr = asAppError(error); + if (targetsAndroid && wantsAndroidHeadless && !fallbackAvdName && appErr.code === 'DEVICE_NOT_FOUND') { + return { + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'boot --headless requires --device (or an Android emulator session target).', + }, + }; + } + if (!canFallbackLaunchAndroidEmulator || appErr.code !== 'DEVICE_NOT_FOUND' || !fallbackAvdName) { + throw error; + } + device = await ensureAndroidEmulatorBootOverride({ + avdName: fallbackAvdName, + serial: flags.serial, + headless: wantsAndroidHeadless, + }); + launchedAndroidEmulator = true; + } + if (targetsAndroid && wantsAndroidHeadless) { + if (device.platform !== 'android' || device.kind !== 'emulator') { + return { + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'boot --headless is supported only for Android emulators.', + }, + }; + } + if (!launchedAndroidEmulator) { + const avdName = resolveAndroidEmulatorAvdName({ + flags, + sessionDevice: session?.device, + resolvedDevice: device, + }); + if (!avdName) { + return { + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'boot --headless requires --device (or an Android emulator session target).', + }, + }; + } + device = await ensureAndroidEmulatorBootOverride({ + avdName, + serial: flags.serial, + headless: true, + }); + } + await ensureReady(device); + } else { + const shouldEnsureReady = device.platform !== 'android' || device.booted !== true; + if (shouldEnsureReady) { + await ensureReady(device); + } + } if (!isCommandSupportedOnDevice('boot', device)) { return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } }; } diff --git a/src/platforms/android/__tests__/devices.test.ts b/src/platforms/android/__tests__/devices.test.ts index 5433c4c2e..975fcaaea 100644 --- a/src/platforms/android/__tests__/devices.test.ts +++ b/src/platforms/android/__tests__/devices.test.ts @@ -1,8 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { + ensureAndroidEmulatorBooted, + parseAndroidAvdList, parseAndroidFeatureListForTv, parseAndroidTargetFromCharacteristics, + resolveAndroidBootSelectorDevice, + resolveAndroidAvdName, } from '../devices.ts'; test('parseAndroidTargetFromCharacteristics detects tv markers', () => { @@ -19,3 +26,178 @@ test('parseAndroidFeatureListForTv detects television and leanback features', () assert.equal(parseAndroidFeatureListForTv(tvFeatures), true); assert.equal(parseAndroidFeatureListForTv('feature:android.hardware.camera'), false); }); + +test('parseAndroidAvdList drops empty lines', () => { + const listed = parseAndroidAvdList('\nPixel_9_Pro_XL\n\nWear_OS\n'); + assert.deepEqual(listed, ['Pixel_9_Pro_XL', 'Wear_OS']); +}); + +test('resolveAndroidAvdName supports space vs underscore matching', () => { + const avdNames = ['Pixel_9_Pro_XL', 'Medium_Tablet_API_35']; + assert.equal(resolveAndroidAvdName(avdNames, 'Pixel_9_Pro_XL'), 'Pixel_9_Pro_XL'); + assert.equal(resolveAndroidAvdName(avdNames, 'pixel 9 pro xl'), 'Pixel_9_Pro_XL'); + assert.equal(resolveAndroidAvdName(avdNames, 'unknown'), undefined); +}); + +async function withMockedAndroidTools( + run: (ctx: { emulatorLogPath: string; emulatorBootedPath: string }) => Promise, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-headless-')); + const emulatorLogPath = path.join(tmpDir, 'emulator.log'); + const emulatorBootedPath = path.join(tmpDir, 'emulator.booted'); + const adbPath = path.join(tmpDir, 'adb'); + const emulatorPath = path.join(tmpDir, 'emulator'); + + await fs.writeFile( + adbPath, + [ + '#!/bin/sh', + 'if [ "$1" = "devices" ] && [ "$2" = "-l" ]; then', + ' echo "List of devices attached"', + ' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then', + ' echo "emulator-5554 device product:sdk_gphone64 model:Pixel_9_Pro_XL device:emu64a transport_id:2"', + ' fi', + ' exit 0', + 'fi', + 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "emu" ] && [ "$4" = "avd" ] && [ "$5" = "name" ]; then', + ' echo "Pixel_9_Pro_XL"', + ' exit 0', + 'fi', + 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.boot.qemu.avd_name" ]; then', + ' echo "Pixel_9_Pro_XL"', + ' exit 0', + 'fi', + 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "persist.sys.avd_name" ]; then', + ' echo "Pixel_9_Pro_XL"', + ' exit 0', + 'fi', + 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "sys.boot_completed" ]; then', + ' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then', + ' echo "1"', + ' else', + ' echo "0"', + ' fi', + ' exit 0', + 'fi', + 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.build.characteristics" ]; then', + ' echo "phone"', + ' exit 0', + 'fi', + 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "cmd" ] && [ "$5" = "package" ] && [ "$6" = "has-feature" ]; then', + ' echo "false"', + ' exit 0', + 'fi', + 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "list" ] && [ "$6" = "features" ]; then', + ' echo ""', + ' exit 0', + 'fi', + 'echo "unexpected adb args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"', + 'exit 1', + '', + ].join('\n'), + 'utf8', + ); + await fs.writeFile( + emulatorPath, + [ + '#!/bin/sh', + 'if [ "$1" = "-list-avds" ]; then', + ' echo "Pixel_9_Pro_XL"', + ' exit 0', + 'fi', + 'if [ "$1" = "-avd" ]; then', + ' echo "$@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"', + ' touch "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE"', + ' exit 0', + 'fi', + 'echo "unexpected emulator args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"', + 'exit 1', + '', + ].join('\n'), + 'utf8', + ); + await fs.chmod(adbPath, 0o755); + await fs.chmod(emulatorPath, 0o755); + + const previousPath = process.env.PATH; + const previousBooted = process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE; + const previousLog = process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE = emulatorBootedPath; + process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE = emulatorLogPath; + + try { + await run({ emulatorLogPath, emulatorBootedPath }); + } finally { + process.env.PATH = previousPath; + if (previousBooted === undefined) delete process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE; + else process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE = previousBooted; + if (previousLog === undefined) delete process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE; + else process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE = previousLog; + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +test('ensureAndroidEmulatorBooted launches emulator in headless mode when requested', async () => { + await withMockedAndroidTools(async ({ emulatorLogPath, emulatorBootedPath }) => { + const device = await ensureAndroidEmulatorBooted({ + avdName: 'Pixel 9 Pro XL', + timeoutMs: 5_000, + headless: true, + }); + assert.equal(device.platform, 'android'); + assert.equal(device.kind, 'emulator'); + assert.equal(device.id, 'emulator-5554'); + assert.equal(device.booted, true); + const log = await fs.readFile(emulatorLogPath, 'utf8'); + assert.match(log, /-avd Pixel_9_Pro_XL -no-window -no-audio/); + await fs.access(emulatorBootedPath); + }); +}); + +test('ensureAndroidEmulatorBooted reuses running emulator for headless requests', async () => { + await withMockedAndroidTools(async ({ emulatorLogPath, emulatorBootedPath }) => { + await fs.writeFile(emulatorBootedPath, 'ready', 'utf8'); + const device = await ensureAndroidEmulatorBooted({ + avdName: 'Pixel_9_Pro_XL', + timeoutMs: 5_000, + headless: true, + }); + assert.equal(device.id, 'emulator-5554'); + const log = await fs.readFile(emulatorLogPath, 'utf8').catch(() => ''); + assert.equal(log.trim(), ''); + }); +}); + +test('ensureAndroidEmulatorBooted launches emulator with GUI by default', async () => { + await withMockedAndroidTools(async ({ emulatorLogPath }) => { + const device = await ensureAndroidEmulatorBooted({ + avdName: 'Pixel_9_Pro_XL', + timeoutMs: 5_000, + }); + assert.equal(device.id, 'emulator-5554'); + const log = await fs.readFile(emulatorLogPath, 'utf8'); + assert.match(log, /-avd Pixel_9_Pro_XL/); + assert.doesNotMatch(log, /-no-window/); + }); +}); + +test('resolveAndroidBootSelectorDevice matches emulator by device name', async () => { + await withMockedAndroidTools(async () => { + await fs.writeFile(process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE!, 'ready', 'utf8'); + const device = await resolveAndroidBootSelectorDevice({ deviceName: 'Pixel 9 Pro XL' }); + assert.ok(device); + assert.equal(device?.id, 'emulator-5554'); + assert.equal(device?.kind, 'emulator'); + assert.equal(device?.booted, true); + }); +}); + +test('resolveAndroidBootSelectorDevice matches by serial', async () => { + await withMockedAndroidTools(async () => { + await fs.writeFile(process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE!, 'ready', 'utf8'); + const device = await resolveAndroidBootSelectorDevice({ serial: 'emulator-5554' }); + assert.ok(device); + assert.equal(device?.id, 'emulator-5554'); + }); +}); diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index b21d1beff..e812e6492 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -1,4 +1,4 @@ -import { runCmd, whichCmd } from '../../utils/exec.ts'; +import { runCmd, runCmdDetached, whichCmd } from '../../utils/exec.ts'; import type { ExecResult } from '../../utils/exec.ts'; import { AppError, asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; @@ -7,6 +7,9 @@ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; const EMULATOR_SERIAL_PREFIX = 'emulator-'; const ANDROID_BOOT_POLL_MS = 1000; +const ANDROID_EMULATOR_BOOT_POLL_MS = 1000; +const ANDROID_EMULATOR_BOOT_TIMEOUT_MS = 120_000; +const ANDROID_EMULATOR_AVD_NAME_TIMEOUT_MS = 1_500; const ANDROID_TV_FEATURES = [ 'android.software.leanback', 'android.software.leanback_only', @@ -25,6 +28,10 @@ function isEmulatorSerial(serial: string): boolean { return serial.startsWith(EMULATOR_SERIAL_PREFIX); } +function normalizeAndroidName(value: string): string { + return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); +} + async function readAndroidBootProp( serial: string, timeoutMs = TIMEOUT_PROFILES.android_boot.operationMs, @@ -38,15 +45,32 @@ async function readAndroidBootProp( async function resolveAndroidDeviceName(serial: string, rawModel: string): Promise { const modelName = rawModel.replace(/_/g, ' ').trim(); if (!isEmulatorSerial(serial)) return modelName || serial; - const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), { + const avdName = await resolveAndroidEmulatorAvdName(serial); + if (avdName) return avdName.replace(/_/g, ' '); + return modelName || serial; +} + +async function resolveAndroidEmulatorAvdName(serial: string): Promise { + const avdPropKeys = ['ro.boot.qemu.avd_name', 'persist.sys.avd_name']; + for (const prop of avdPropKeys) { + const result = await runCmd('adb', adbArgs(serial, ['shell', 'getprop', prop]), { + allowFailure: true, + timeoutMs: ANDROID_EMULATOR_AVD_NAME_TIMEOUT_MS, + }); + const value = result.stdout.trim(); + if (result.exitCode === 0 && value.length > 0) { + return value; + } + } + const emuResult = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), { allowFailure: true, - timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs, + timeoutMs: ANDROID_EMULATOR_AVD_NAME_TIMEOUT_MS, }); - const avdName = avd.stdout.trim(); - if (avd.exitCode === 0 && avdName) { - return avdName.replace(/_/g, ' '); + const emuValue = emuResult.stdout.trim(); + if (emuResult.exitCode === 0 && emuValue.length > 0) { + return emuValue; } - return modelName || serial; + return undefined; } export function parseAndroidTargetFromCharacteristics(rawOutput: string): 'tv' | null { @@ -118,18 +142,7 @@ export async function listAndroidDevices(): Promise { throw new AppError('TOOL_MISSING', 'adb not found in PATH'); } - const result = await runCmd('adb', ['devices', '-l'], { - timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs, - }); - const lines = result.stdout.split('\n').map((l: string) => l.trim()); - const entries = lines - .filter((line) => line.length > 0 && !line.startsWith('List of devices')) - .map((line) => line.split(/\s+/)) - .filter((parts) => parts[1] === 'device') - .map((parts) => ({ - serial: parts[0], - rawModel: (parts.find((p: string) => p.startsWith('model:')) ?? '').replace('model:', ''), - })); + const entries = await listAndroidDeviceEntries(); const devices = await Promise.all(entries.map(async ({ serial, rawModel }) => { const [name, booted, target] = await Promise.all([ @@ -150,6 +163,181 @@ export async function listAndroidDevices(): Promise { return devices; } +type AndroidDeviceEntry = { + serial: string; + rawModel: string; +}; + +function parseAndroidDeviceEntries(rawOutput: string): AndroidDeviceEntry[] { + const lines = rawOutput.split('\n').map((line) => line.trim()); + return lines + .filter((line) => line.length > 0 && !line.startsWith('List of devices')) + .map((line) => line.split(/\s+/)) + .filter((parts) => parts[1] === 'device') + .map((parts) => ({ + serial: parts[0], + rawModel: (parts.find((entry) => entry.startsWith('model:')) ?? '').replace('model:', ''), + })); +} + +async function listAndroidDeviceEntries(): Promise { + const result = await runCmd('adb', ['devices', '-l'], { + timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs, + }); + return parseAndroidDeviceEntries(result.stdout); +} + +export async function resolveAndroidBootSelectorDevice(params: { + deviceName?: string; + serial?: string; +}): Promise { + const adbAvailable = await whichCmd('adb'); + if (!adbAvailable) { + throw new AppError('TOOL_MISSING', 'adb not found in PATH'); + } + + const entries = await listAndroidDeviceEntries(); + if (entries.length === 0) return undefined; + + const serialSelector = params.serial?.trim(); + const deviceNameSelector = params.deviceName?.trim(); + + let matched: AndroidDeviceEntry | undefined; + let matchedName: string | undefined; + + if (serialSelector) { + matched = entries.find((entry) => entry.serial === serialSelector); + if (!matched) return undefined; + matchedName = await resolveAndroidDeviceName(matched.serial, matched.rawModel); + } else if (deviceNameSelector) { + const target = normalizeAndroidName(deviceNameSelector); + for (const entry of entries) { + const modelName = entry.rawModel.replace(/_/g, ' ').trim(); + if (normalizeAndroidName(modelName) === target) { + matched = entry; + matchedName = modelName || entry.serial; + break; + } + const resolvedName = await resolveAndroidDeviceName(entry.serial, entry.rawModel); + if (normalizeAndroidName(resolvedName) === target) { + matched = entry; + matchedName = resolvedName; + break; + } + } + } else { + return undefined; + } + + if (!matched) return undefined; + const booted = await isAndroidBooted(matched.serial); + return { + platform: 'android', + id: matched.serial, + name: matchedName ?? (matched.rawModel.replace(/_/g, ' ').trim() || matched.serial), + kind: isEmulatorSerial(matched.serial) ? 'emulator' : 'device', + booted, + }; +} + +export function parseAndroidAvdList(rawOutput: string): string[] { + return rawOutput + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +export function resolveAndroidAvdName(avdNames: string[], requestedName: string): string | undefined { + const direct = avdNames.find((name) => name === requestedName); + if (direct) return direct; + const target = normalizeAndroidName(requestedName); + return avdNames.find((name) => normalizeAndroidName(name) === target); +} + +async function listAndroidAvdNames(): Promise { + const result = await runCmd('emulator', ['-list-avds'], { + allowFailure: true, + timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to list Android emulator AVDs', { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + hint: 'Verify Android emulator tooling is installed and available in PATH.', + }); + } + return parseAndroidAvdList(result.stdout); +} + +function findAndroidEmulatorByAvdName( + devices: DeviceInfo[], + avdName: string, + serial?: string, +): DeviceInfo | undefined { + const target = normalizeAndroidName(avdName); + return devices.find((device) => { + if (device.platform !== 'android' || device.kind !== 'emulator') return false; + if (serial && device.id !== serial) return false; + return normalizeAndroidName(device.name) === target; + }); +} + +async function waitForAndroidEmulatorByAvdName(params: { + avdName: string; + serial?: string; + timeoutMs: number; +}): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < params.timeoutMs) { + try { + const serial = await findAndroidEmulatorSerialByAvdName(params.avdName, params.serial); + if (serial) { + return { + platform: 'android', + id: serial, + name: params.avdName, + kind: 'emulator', + target: 'mobile', + booted: false, + }; + } + } catch { + // Best-effort polling while adb/emulator process settles. + } + await new Promise((resolve) => setTimeout(resolve, ANDROID_EMULATOR_BOOT_POLL_MS)); + } + throw new AppError('COMMAND_FAILED', 'Android emulator did not appear in time', { + avdName: params.avdName, + serial: params.serial, + timeoutMs: params.timeoutMs, + hint: 'Check emulator logs and verify the AVD can start from command line.', + }); +} + +async function findAndroidEmulatorSerialByAvdName( + avdName: string, + serial?: string, +): Promise { + const target = normalizeAndroidName(avdName); + const entries = await listAndroidDeviceEntries(); + const candidates = entries.filter((entry) => { + if (serial && entry.serial !== serial) return false; + return isEmulatorSerial(entry.serial); + }); + + for (const entry of candidates) { + if (normalizeAndroidName(entry.rawModel) === target) { + return entry.serial; + } + const resolvedName = await resolveAndroidDeviceName(entry.serial, entry.rawModel); + if (normalizeAndroidName(resolvedName) === target) { + return entry.serial; + } + } + return undefined; +} + async function isAndroidBooted(serial: string): Promise { try { const result = await readAndroidBootProp(serial); @@ -159,6 +347,82 @@ async function isAndroidBooted(serial: string): Promise { } } +export async function ensureAndroidEmulatorBooted(params: { + avdName: string; + serial?: string; + timeoutMs?: number; + headless?: boolean; +}): Promise { + const requestedAvdName = params.avdName.trim(); + if (!requestedAvdName) { + throw new AppError('INVALID_ARGS', 'Android emulator boot requires a non-empty AVD name.'); + } + const timeoutMs = params.timeoutMs ?? ANDROID_EMULATOR_BOOT_TIMEOUT_MS; + + if (!(await whichCmd('adb'))) { + throw new AppError('TOOL_MISSING', 'adb not found in PATH'); + } + if (!(await whichCmd('emulator'))) { + throw new AppError('TOOL_MISSING', 'emulator not found in PATH'); + } + + const avdNames = await listAndroidAvdNames(); + const resolvedAvdName = resolveAndroidAvdName(avdNames, requestedAvdName); + if (!resolvedAvdName) { + throw new AppError('DEVICE_NOT_FOUND', `No Android emulator AVD named ${params.avdName}`, { + requestedAvdName, + availableAvds: avdNames, + hint: 'Run `emulator -list-avds` and pass an existing AVD name to --device.', + }); + } + + const startedAt = Date.now(); + const existing = findAndroidEmulatorByAvdName(await listAndroidDevices(), resolvedAvdName, params.serial); + if (!existing) { + const launchArgs = ['-avd', resolvedAvdName]; + if (params.headless) { + launchArgs.push('-no-window', '-no-audio'); + } + runCmdDetached('emulator', launchArgs); + } + + const discovered = + existing + ?? (await waitForAndroidEmulatorByAvdName({ + avdName: resolvedAvdName, + serial: params.serial, + timeoutMs, + })); + + const elapsedMs = Date.now() - startedAt; + const remainingMs = Math.max(1_000, timeoutMs - elapsedMs); + await waitForAndroidBoot(discovered.id, remainingMs); + const refreshed = (await listAndroidDevices()).find((device) => device.id === discovered.id); + if (refreshed) { + return { + ...refreshed, + name: resolvedAvdName, + booted: true, + }; + } + return { + ...discovered, + name: resolvedAvdName, + booted: true, + }; +} + +export async function ensureAndroidEmulatorHeadlessBooted(params: { + avdName: string; + serial?: string; + timeoutMs?: number; +}): Promise { + return await ensureAndroidEmulatorBooted({ + ...params, + headless: true, + }); +} + export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise { const timeoutBudget = timeoutMs; const deadline = Deadline.fromTimeoutMs(timeoutBudget); diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 9adc50351..d5e5663eb 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -19,6 +19,14 @@ test('parseArgs recognizes --target selector', () => { assert.equal(parsed.flags.target, 'tv'); }); +test('parseArgs recognizes boot --headless flag', () => { + const parsed = parseArgs(['boot', '--platform', 'android', '--device', 'Pixel_9_Pro_XL', '--headless'], { strictFlags: true }); + assert.equal(parsed.command, 'boot'); + assert.equal(parsed.flags.platform, 'android'); + assert.equal(parsed.flags.device, 'Pixel_9_Pro_XL'); + assert.equal(parsed.flags.headless, true); +}); + test('parseArgs recognizes --platform apple alias', () => { const parsed = parseArgs(['open', 'Settings', '--platform', 'apple', '--target', 'tv'], { strictFlags: true }); assert.equal(parsed.command, 'open'); diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index b18ebd81d..8bb0c07ab 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -34,6 +34,7 @@ export type CliFlags = { activity?: string; saveScript?: boolean | string; relaunch?: boolean; + headless?: boolean; restart?: boolean; noRecord?: boolean; replayUpdate?: boolean; @@ -181,6 +182,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--serial ', usageDescription: 'Android device serial', }, + { + key: 'headless', + names: ['--headless'], + type: 'boolean', + usageLabel: '--headless', + usageDescription: 'Boot: launch Android emulator without a GUI window', + }, { key: 'activity', names: ['--activity'], @@ -444,7 +452,7 @@ const COMMAND_SCHEMAS: Record = { boot: { description: 'Ensure target device/simulator is booted and ready', positionalArgs: [], - allowedFlags: [], + allowedFlags: ['headless'], }, open: { description: 'Boot device/simulator; optionally launch app or deep link URL', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e86e55454..af8b4653a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -12,6 +12,7 @@ This page summarizes the primary command groups. agent-device boot agent-device boot --platform ios agent-device boot --platform android +agent-device boot --platform android --device Pixel_9_Pro_XL --headless agent-device open [app|url] [url] agent-device close [app] agent-device back @@ -24,6 +25,8 @@ agent-device app-switcher - `--platform apple` is an alias for the iOS/tvOS backend. - Use `--target mobile|tv` with `--platform` (required) to select phone/tablet vs TV-class devices (`AndroidTV`, `tvOS`). - `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available. +- Android: `boot --platform android --device ` launches that emulator in GUI mode when needed. +- Android: add `--headless` to launch without opening a GUI window. - `open [app|url] [url]` already boots/activates the selected target when needed. - `open ` deep links are supported on Android and iOS. - `open ` opens a deep link on iOS. diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index 54dcd6710..b0eaa1e34 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -30,6 +30,10 @@ Boot target if there is no ready device/simulator: ```bash agent-device boot --platform ios # or android +# Android emulator launch by AVD name (GUI mode): +agent-device boot --platform android --device Pixel_9_Pro_XL +# Android headless emulator boot (AVD name): +agent-device boot --platform android --device Pixel_9_Pro_XL --headless ``` ## Common commands From c206a0b4ddc1c8afd6b776a68f400961619af5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 25 Feb 2026 20:54:16 +0100 Subject: [PATCH 2/5] Refactor Android boot fast lookup for testability --- src/daemon/handlers/__tests__/session.test.ts | 48 +++++++++++++++++-- src/daemon/handlers/session.ts | 31 ++++++++---- .../android/__tests__/devices.test.ts | 1 + src/platforms/android/devices.ts | 6 ++- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 5d0faaa37..0860b6663 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -379,7 +379,7 @@ test('boot --headless launches Android emulator when no running device matches', session: 'default', command: 'boot', positionals: [], - flags: { platform: 'android', target: 'mobile', device: 'Pixel_9_Pro_XL', headless: true }, + flags: { platform: 'android', device: 'Pixel_9_Pro_XL', headless: true }, }, sessionName: 'default', logPath: path.join(os.tmpdir(), 'daemon.log'), @@ -391,6 +391,7 @@ test('boot --headless launches Android emulator when no running device matches', resolveTargetDevice: async () => { throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); }, + resolveAndroidBootSelectorDevice: async () => undefined, ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => { launchCalls.push({ avdName, serial, headless }); return { @@ -424,7 +425,7 @@ test('boot launches Android emulator with GUI when no running device matches', a session: 'default', command: 'boot', positionals: [], - flags: { platform: 'android', target: 'mobile', device: 'Pixel_9_Pro_XL' }, + flags: { platform: 'android', device: 'Pixel_9_Pro_XL' }, }, sessionName: 'default', logPath: path.join(os.tmpdir(), 'daemon.log'), @@ -434,6 +435,7 @@ test('boot launches Android emulator with GUI when no running device matches', a resolveTargetDevice: async () => { throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); }, + resolveAndroidBootSelectorDevice: async () => undefined, ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => { launchCalls.push({ avdName, serial, headless }); return { @@ -466,7 +468,7 @@ test('boot --headless requires avd selector when device cannot be resolved', asy session: 'default', command: 'boot', positionals: [], - flags: { platform: 'android', target: 'mobile', serial: 'emulator-5554', headless: true }, + flags: { platform: 'android', serial: 'emulator-5554', headless: true }, }, sessionName: 'default', logPath: path.join(os.tmpdir(), 'daemon.log'), @@ -476,6 +478,7 @@ test('boot --headless requires avd selector when device cannot be resolved', asy resolveTargetDevice: async () => { throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); }, + resolveAndroidBootSelectorDevice: async () => undefined, ensureAndroidEmulatorBoot: async () => { bootCalled = true; throw new Error('unexpected'); @@ -491,6 +494,45 @@ test('boot --headless requires avd selector when device cannot be resolved', asy } }); +test('boot uses fast Android selector lookup for already booted device', async () => { + const sessionStore = makeSessionStore(); + let ensureCalls = 0; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', device: 'Pixel 9 Pro XL' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ensureReady: async () => { + ensureCalls += 1; + }, + resolveTargetDevice: async () => { + throw new Error('resolveTargetDevice should not be called when fast lookup succeeds'); + }, + resolveAndroidBootSelectorDevice: async () => ({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel 9 Pro XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }), + }); + assert.ok(response); + assert.equal(response?.ok, true); + assert.equal(ensureCalls, 0); + if (response && response.ok) { + assert.equal(response.data?.platform, 'android'); + assert.equal(response.data?.id, 'emulator-5554'); + } +}); + test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index ce69b2e4f..7a3d363eb 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -54,6 +54,11 @@ type EnsureAndroidEmulatorBoot = (params: { headless?: boolean; }) => Promise; +type ResolveAndroidBootSelectorDevice = (params: { + deviceName?: string; + serial?: string; +}) => Promise; + const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "" ).'; const BATCH_PARENT_FLAG_KEYS: Array = ['platform', 'target', 'device', 'udid', 'serial', 'verbose', 'out']; @@ -253,6 +258,11 @@ const defaultEnsureAndroidEmulatorBoot: EnsureAndroidEmulatorBoot = async ({ avd return await ensureAndroidEmulatorBooted({ avdName, serial, headless }); }; +const defaultResolveAndroidBootSelectorDevice: ResolveAndroidBootSelectorDevice = async ({ deviceName, serial }) => { + const { resolveAndroidBootSelectorDevice } = await import('../../platforms/android/devices.ts'); + return await resolveAndroidBootSelectorDevice({ deviceName, serial }); +}; + const defaultReinstallOps: ReinstallOps = { ios: async (device, app, appPath) => { const { reinstallIosApp } = await import('../../platforms/ios/index.ts'); @@ -470,6 +480,7 @@ export async function handleSessionCommands(params: { stop: typeof stopAppLog; }; ensureAndroidEmulatorBoot?: EnsureAndroidEmulatorBoot; + resolveAndroidBootSelectorDevice?: ResolveAndroidBootSelectorDevice; resolveAndroidPackageForOpen?: ( device: DeviceInfo, openTarget: string | undefined, @@ -491,6 +502,7 @@ export async function handleSessionCommands(params: { stop: stopAppLog, }, ensureAndroidEmulatorBoot: ensureAndroidEmulatorBootOverride = defaultEnsureAndroidEmulatorBoot, + resolveAndroidBootSelectorDevice: resolveAndroidBootSelectorDeviceOverride = defaultResolveAndroidBootSelectorDevice, resolveAndroidPackageForOpen: resolveAndroidPackageForOpenOverride = resolveAndroidPackageForOpen, } = params; const dispatch = dispatchOverride ?? dispatchCommand; @@ -587,7 +599,7 @@ export async function handleSessionCommands(params: { const normalizedPlatform = normalizePlatformSelector(flags.platform) ?? session?.device.platform; const targetsAndroid = normalizedPlatform === 'android'; const wantsAndroidHeadless = flags.headless === true; - const shouldUseFastAndroidSelectorLookup = targetsAndroid && !flags.target && Boolean(flags.device || flags.serial); + const shouldUseFastAndroidSelectorLookup = targetsAndroid && Boolean(flags.device || flags.serial); const fallbackAvdName = resolveAndroidEmulatorAvdName({ flags, sessionDevice: session?.device, @@ -595,15 +607,16 @@ export async function handleSessionCommands(params: { const canFallbackLaunchAndroidEmulator = targetsAndroid && Boolean(fallbackAvdName); let device: DeviceInfo; let launchedAndroidEmulator = false; - const fastLookupDevice = shouldUseFastAndroidSelectorLookup - ? await (async () => { - const { resolveAndroidBootSelectorDevice } = await import('../../platforms/android/devices.ts'); - return await resolveAndroidBootSelectorDevice({ - deviceName: flags.device, - serial: flags.serial, - }); - })() + const fastLookupDeviceCandidate = shouldUseFastAndroidSelectorLookup + ? await resolveAndroidBootSelectorDeviceOverride({ + deviceName: flags.device, + serial: flags.serial, + }) : undefined; + const targetMismatch = Boolean(flags.target) + && fastLookupDeviceCandidate + && (fastLookupDeviceCandidate.target ?? 'mobile') !== flags.target; + const fastLookupDevice = targetMismatch ? undefined : fastLookupDeviceCandidate; try { device = fastLookupDevice ?? (await resolveCommandDevice({ diff --git a/src/platforms/android/__tests__/devices.test.ts b/src/platforms/android/__tests__/devices.test.ts index 975fcaaea..a13cdc901 100644 --- a/src/platforms/android/__tests__/devices.test.ts +++ b/src/platforms/android/__tests__/devices.test.ts @@ -189,6 +189,7 @@ test('resolveAndroidBootSelectorDevice matches emulator by device name', async ( assert.ok(device); assert.equal(device?.id, 'emulator-5554'); assert.equal(device?.kind, 'emulator'); + assert.equal(device?.target, 'mobile'); assert.equal(device?.booted, true); }); }); diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index e812e6492..a3cb012d0 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -230,12 +230,16 @@ export async function resolveAndroidBootSelectorDevice(params: { } if (!matched) return undefined; - const booted = await isAndroidBooted(matched.serial); + const [booted, target] = await Promise.all([ + isAndroidBooted(matched.serial), + resolveAndroidTarget(matched.serial), + ]); return { platform: 'android', id: matched.serial, name: matchedName ?? (matched.rawModel.replace(/_/g, ' ').trim() || matched.serial), kind: isEmulatorSerial(matched.serial) ? 'emulator' : 'device', + target, booted, }; } From 1389737c0176a56e58f3e6dcc60af1cdb4a8d439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 25 Feb 2026 20:56:31 +0100 Subject: [PATCH 3/5] Avoid extra target probes in Android boot fast lookup --- src/daemon/handlers/session.ts | 6 ++++-- src/platforms/android/__tests__/devices.test.ts | 2 +- src/platforms/android/devices.ts | 7 +++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 7a3d363eb..7abba73b7 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -57,6 +57,7 @@ type EnsureAndroidEmulatorBoot = (params: { type ResolveAndroidBootSelectorDevice = (params: { deviceName?: string; serial?: string; + includeTarget?: boolean; }) => Promise; const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = @@ -258,9 +259,9 @@ const defaultEnsureAndroidEmulatorBoot: EnsureAndroidEmulatorBoot = async ({ avd return await ensureAndroidEmulatorBooted({ avdName, serial, headless }); }; -const defaultResolveAndroidBootSelectorDevice: ResolveAndroidBootSelectorDevice = async ({ deviceName, serial }) => { +const defaultResolveAndroidBootSelectorDevice: ResolveAndroidBootSelectorDevice = async ({ deviceName, serial, includeTarget }) => { const { resolveAndroidBootSelectorDevice } = await import('../../platforms/android/devices.ts'); - return await resolveAndroidBootSelectorDevice({ deviceName, serial }); + return await resolveAndroidBootSelectorDevice({ deviceName, serial, includeTarget }); }; const defaultReinstallOps: ReinstallOps = { @@ -611,6 +612,7 @@ export async function handleSessionCommands(params: { ? await resolveAndroidBootSelectorDeviceOverride({ deviceName: flags.device, serial: flags.serial, + includeTarget: Boolean(flags.target), }) : undefined; const targetMismatch = Boolean(flags.target) diff --git a/src/platforms/android/__tests__/devices.test.ts b/src/platforms/android/__tests__/devices.test.ts index a13cdc901..4812d1ef4 100644 --- a/src/platforms/android/__tests__/devices.test.ts +++ b/src/platforms/android/__tests__/devices.test.ts @@ -185,7 +185,7 @@ test('ensureAndroidEmulatorBooted launches emulator with GUI by default', async test('resolveAndroidBootSelectorDevice matches emulator by device name', async () => { await withMockedAndroidTools(async () => { await fs.writeFile(process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE!, 'ready', 'utf8'); - const device = await resolveAndroidBootSelectorDevice({ deviceName: 'Pixel 9 Pro XL' }); + const device = await resolveAndroidBootSelectorDevice({ deviceName: 'Pixel 9 Pro XL', includeTarget: true }); assert.ok(device); assert.equal(device?.id, 'emulator-5554'); assert.equal(device?.kind, 'emulator'); diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index a3cb012d0..aada8055c 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -190,6 +190,7 @@ async function listAndroidDeviceEntries(): Promise { export async function resolveAndroidBootSelectorDevice(params: { deviceName?: string; serial?: string; + includeTarget?: boolean; }): Promise { const adbAvailable = await whichCmd('adb'); if (!adbAvailable) { @@ -230,10 +231,8 @@ export async function resolveAndroidBootSelectorDevice(params: { } if (!matched) return undefined; - const [booted, target] = await Promise.all([ - isAndroidBooted(matched.serial), - resolveAndroidTarget(matched.serial), - ]); + const booted = await isAndroidBooted(matched.serial); + const target = params.includeTarget ? await resolveAndroidTarget(matched.serial) : undefined; return { platform: 'android', id: matched.serial, From 9b4aa0bebb5122fd834e1fc687962a6eba27bfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 25 Feb 2026 21:00:57 +0100 Subject: [PATCH 4/5] Simplify Android boot path in session handler --- src/daemon/handlers/__tests__/session.test.ts | 42 -------------- src/daemon/handlers/session.ts | 40 +++---------- .../android/__tests__/devices.test.ts | 22 -------- src/platforms/android/devices.ts | 56 ------------------- 4 files changed, 7 insertions(+), 153 deletions(-) diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 0860b6663..b16ab004c 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -391,7 +391,6 @@ test('boot --headless launches Android emulator when no running device matches', resolveTargetDevice: async () => { throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); }, - resolveAndroidBootSelectorDevice: async () => undefined, ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => { launchCalls.push({ avdName, serial, headless }); return { @@ -435,7 +434,6 @@ test('boot launches Android emulator with GUI when no running device matches', a resolveTargetDevice: async () => { throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); }, - resolveAndroidBootSelectorDevice: async () => undefined, ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => { launchCalls.push({ avdName, serial, headless }); return { @@ -478,7 +476,6 @@ test('boot --headless requires avd selector when device cannot be resolved', asy resolveTargetDevice: async () => { throw new AppError('DEVICE_NOT_FOUND', 'No devices found'); }, - resolveAndroidBootSelectorDevice: async () => undefined, ensureAndroidEmulatorBoot: async () => { bootCalled = true; throw new Error('unexpected'); @@ -494,45 +491,6 @@ test('boot --headless requires avd selector when device cannot be resolved', asy } }); -test('boot uses fast Android selector lookup for already booted device', async () => { - const sessionStore = makeSessionStore(); - let ensureCalls = 0; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'android', device: 'Pixel 9 Pro XL' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - ensureReady: async () => { - ensureCalls += 1; - }, - resolveTargetDevice: async () => { - throw new Error('resolveTargetDevice should not be called when fast lookup succeeds'); - }, - resolveAndroidBootSelectorDevice: async () => ({ - platform: 'android', - id: 'emulator-5554', - name: 'Pixel 9 Pro XL', - kind: 'emulator', - target: 'mobile', - booted: true, - }), - }); - assert.ok(response); - assert.equal(response?.ok, true); - assert.equal(ensureCalls, 0); - if (response && response.ok) { - assert.equal(response.data?.platform, 'android'); - assert.equal(response.data?.id, 'emulator-5554'); - } -}); - test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 7abba73b7..088de632d 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -54,12 +54,6 @@ type EnsureAndroidEmulatorBoot = (params: { headless?: boolean; }) => Promise; -type ResolveAndroidBootSelectorDevice = (params: { - deviceName?: string; - serial?: string; - includeTarget?: boolean; -}) => Promise; - const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "" ).'; const BATCH_PARENT_FLAG_KEYS: Array = ['platform', 'target', 'device', 'udid', 'serial', 'verbose', 'out']; @@ -259,11 +253,6 @@ const defaultEnsureAndroidEmulatorBoot: EnsureAndroidEmulatorBoot = async ({ avd return await ensureAndroidEmulatorBooted({ avdName, serial, headless }); }; -const defaultResolveAndroidBootSelectorDevice: ResolveAndroidBootSelectorDevice = async ({ deviceName, serial, includeTarget }) => { - const { resolveAndroidBootSelectorDevice } = await import('../../platforms/android/devices.ts'); - return await resolveAndroidBootSelectorDevice({ deviceName, serial, includeTarget }); -}; - const defaultReinstallOps: ReinstallOps = { ios: async (device, app, appPath) => { const { reinstallIosApp } = await import('../../platforms/ios/index.ts'); @@ -481,7 +470,6 @@ export async function handleSessionCommands(params: { stop: typeof stopAppLog; }; ensureAndroidEmulatorBoot?: EnsureAndroidEmulatorBoot; - resolveAndroidBootSelectorDevice?: ResolveAndroidBootSelectorDevice; resolveAndroidPackageForOpen?: ( device: DeviceInfo, openTarget: string | undefined, @@ -503,7 +491,6 @@ export async function handleSessionCommands(params: { stop: stopAppLog, }, ensureAndroidEmulatorBoot: ensureAndroidEmulatorBootOverride = defaultEnsureAndroidEmulatorBoot, - resolveAndroidBootSelectorDevice: resolveAndroidBootSelectorDeviceOverride = defaultResolveAndroidBootSelectorDevice, resolveAndroidPackageForOpen: resolveAndroidPackageForOpenOverride = resolveAndroidPackageForOpen, } = params; const dispatch = dispatchOverride ?? dispatchCommand; @@ -600,7 +587,6 @@ export async function handleSessionCommands(params: { const normalizedPlatform = normalizePlatformSelector(flags.platform) ?? session?.device.platform; const targetsAndroid = normalizedPlatform === 'android'; const wantsAndroidHeadless = flags.headless === true; - const shouldUseFastAndroidSelectorLookup = targetsAndroid && Boolean(flags.device || flags.serial); const fallbackAvdName = resolveAndroidEmulatorAvdName({ flags, sessionDevice: session?.device, @@ -608,26 +594,14 @@ export async function handleSessionCommands(params: { const canFallbackLaunchAndroidEmulator = targetsAndroid && Boolean(fallbackAvdName); let device: DeviceInfo; let launchedAndroidEmulator = false; - const fastLookupDeviceCandidate = shouldUseFastAndroidSelectorLookup - ? await resolveAndroidBootSelectorDeviceOverride({ - deviceName: flags.device, - serial: flags.serial, - includeTarget: Boolean(flags.target), - }) - : undefined; - const targetMismatch = Boolean(flags.target) - && fastLookupDeviceCandidate - && (fastLookupDeviceCandidate.target ?? 'mobile') !== flags.target; - const fastLookupDevice = targetMismatch ? undefined : fastLookupDeviceCandidate; try { - device = fastLookupDevice - ?? (await resolveCommandDevice({ - session, - flags, - ensureReadyFn: ensureReady, - resolveTargetDeviceFn: resolveDevice, - ensureReady: false, - })); + device = await resolveCommandDevice({ + session, + flags, + ensureReadyFn: ensureReady, + resolveTargetDeviceFn: resolveDevice, + ensureReady: false, + }); } catch (error) { const appErr = asAppError(error); if (targetsAndroid && wantsAndroidHeadless && !fallbackAvdName && appErr.code === 'DEVICE_NOT_FOUND') { diff --git a/src/platforms/android/__tests__/devices.test.ts b/src/platforms/android/__tests__/devices.test.ts index 4812d1ef4..a6f2c2348 100644 --- a/src/platforms/android/__tests__/devices.test.ts +++ b/src/platforms/android/__tests__/devices.test.ts @@ -8,7 +8,6 @@ import { parseAndroidAvdList, parseAndroidFeatureListForTv, parseAndroidTargetFromCharacteristics, - resolveAndroidBootSelectorDevice, resolveAndroidAvdName, } from '../devices.ts'; @@ -181,24 +180,3 @@ test('ensureAndroidEmulatorBooted launches emulator with GUI by default', async assert.doesNotMatch(log, /-no-window/); }); }); - -test('resolveAndroidBootSelectorDevice matches emulator by device name', async () => { - await withMockedAndroidTools(async () => { - await fs.writeFile(process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE!, 'ready', 'utf8'); - const device = await resolveAndroidBootSelectorDevice({ deviceName: 'Pixel 9 Pro XL', includeTarget: true }); - assert.ok(device); - assert.equal(device?.id, 'emulator-5554'); - assert.equal(device?.kind, 'emulator'); - assert.equal(device?.target, 'mobile'); - assert.equal(device?.booted, true); - }); -}); - -test('resolveAndroidBootSelectorDevice matches by serial', async () => { - await withMockedAndroidTools(async () => { - await fs.writeFile(process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE!, 'ready', 'utf8'); - const device = await resolveAndroidBootSelectorDevice({ serial: 'emulator-5554' }); - assert.ok(device); - assert.equal(device?.id, 'emulator-5554'); - }); -}); diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index aada8055c..998e0130a 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -187,62 +187,6 @@ async function listAndroidDeviceEntries(): Promise { return parseAndroidDeviceEntries(result.stdout); } -export async function resolveAndroidBootSelectorDevice(params: { - deviceName?: string; - serial?: string; - includeTarget?: boolean; -}): Promise { - const adbAvailable = await whichCmd('adb'); - if (!adbAvailable) { - throw new AppError('TOOL_MISSING', 'adb not found in PATH'); - } - - const entries = await listAndroidDeviceEntries(); - if (entries.length === 0) return undefined; - - const serialSelector = params.serial?.trim(); - const deviceNameSelector = params.deviceName?.trim(); - - let matched: AndroidDeviceEntry | undefined; - let matchedName: string | undefined; - - if (serialSelector) { - matched = entries.find((entry) => entry.serial === serialSelector); - if (!matched) return undefined; - matchedName = await resolveAndroidDeviceName(matched.serial, matched.rawModel); - } else if (deviceNameSelector) { - const target = normalizeAndroidName(deviceNameSelector); - for (const entry of entries) { - const modelName = entry.rawModel.replace(/_/g, ' ').trim(); - if (normalizeAndroidName(modelName) === target) { - matched = entry; - matchedName = modelName || entry.serial; - break; - } - const resolvedName = await resolveAndroidDeviceName(entry.serial, entry.rawModel); - if (normalizeAndroidName(resolvedName) === target) { - matched = entry; - matchedName = resolvedName; - break; - } - } - } else { - return undefined; - } - - if (!matched) return undefined; - const booted = await isAndroidBooted(matched.serial); - const target = params.includeTarget ? await resolveAndroidTarget(matched.serial) : undefined; - return { - platform: 'android', - id: matched.serial, - name: matchedName ?? (matched.rawModel.replace(/_/g, ' ').trim() || matched.serial), - kind: isEmulatorSerial(matched.serial) ? 'emulator' : 'device', - target, - booted, - }; -} - export function parseAndroidAvdList(rawOutput: string): string[] { return rawOutput .split('\n') From 6e7d09dba64567ae9f8c50b091f37fa1ea3ab053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 26 Feb 2026 10:05:16 +0100 Subject: [PATCH 5/5] fix: enforce boot target and headless validation --- src/daemon/handlers/__tests__/session.test.ts | 79 +++++++++++++++++++ src/daemon/handlers/session.ts | 18 +++++ 2 files changed, 97 insertions(+) diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index b16ab004c..7edfeac55 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -491,6 +491,85 @@ test('boot --headless requires avd selector when device cannot be resolved', asy } }); +test('boot --headless rejects non-Android selectors', async () => { + const sessionStore = makeSessionStore(); + let resolved = false; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro', headless: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ensureReady: async () => {}, + resolveTargetDevice: async () => { + resolved = true; + throw new Error('unexpected resolve'); + }, + ensureAndroidEmulatorBoot: async () => { + throw new Error('unexpected emulator launch'); + }, + }); + + assert.ok(response); + assert.equal(response?.ok, false); + assert.equal(resolved, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /headless is supported only for Android emulators/i); + } +}); + +test('boot keeps --target validation when emulator is fallback-launched', async () => { + const sessionStore = makeSessionStore(); + let ensured = false; + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', target: 'tv', device: 'Pixel_9_Pro_XL' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ensureReady: async () => { + ensured = true; + }, + resolveTargetDevice: async () => { + throw new AppError('DEVICE_NOT_FOUND', 'No Android TV devices found'); + }, + ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }, + }); + + assert.ok(response); + assert.equal(response?.ok, false); + assert.equal(ensured, false); + assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); + if (response && !response.ok) { + assert.equal(response.error.code, 'DEVICE_NOT_FOUND'); + assert.match(response.error.message, /matching --target tv/i); + } +}); + test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 088de632d..77a3a7229 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -587,6 +587,15 @@ export async function handleSessionCommands(params: { const normalizedPlatform = normalizePlatformSelector(flags.platform) ?? session?.device.platform; const targetsAndroid = normalizedPlatform === 'android'; const wantsAndroidHeadless = flags.headless === true; + if (wantsAndroidHeadless && !targetsAndroid) { + return { + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'boot --headless is supported only for Android emulators.', + }, + }; + } const fallbackAvdName = resolveAndroidEmulatorAvdName({ flags, sessionDevice: session?.device, @@ -623,6 +632,15 @@ export async function handleSessionCommands(params: { }); launchedAndroidEmulator = true; } + if (flags.target && (device.target ?? 'mobile') !== flags.target) { + return { + ok: false, + error: { + code: 'DEVICE_NOT_FOUND', + message: `No ${device.platform} device found matching --target ${flags.target}.`, + }, + }; + } if (targetsAndroid && wantsAndroidHeadless) { if (device.platform !== 'android' || device.kind !== 'emulator') { return {