Skip to content

Commit 74d4f08

Browse files
committed
Fix Android boot completion and headless emulator launch
1 parent cfc56e8 commit 74d4f08

10 files changed

Lines changed: 732 additions & 26 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,8 @@ Boot diagnostics:
414414
- Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
415415
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.
416416
- Use `agent-device boot --platform ios|android|apple` when starting a new session only if `open` cannot find/connect to an available target.
417+
- Android emulator boot by AVD name (GUI): `agent-device boot --platform android --device Pixel_9_Pro_XL`.
418+
- Android headless emulator boot: `agent-device boot --platform android --device Pixel_9_Pro_XL --headless`.
417419
- `--debug` captures retry telemetry in diagnostics logs.
418420
- Set `AGENT_DEVICE_RETRY_LOGS=1` to also print retry telemetry directly to stderr (ad-hoc troubleshooting).
419421

skills/agent-device/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ agent-device session list
9999
```
100100

101101
Use `boot` only as fallback when `open` cannot find/connect to a ready target.
102+
For Android emulators by AVD name, use `boot --platform android --device <avd-name>`.
103+
For Android emulators without GUI, add `--headless`.
102104
Use `--target mobile|tv` with `--platform` (required) to pick phone/tablet vs TV targets (AndroidTV/tvOS).
103105

104106
TV quick reference:

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from 'node:path';
66
import { handleSessionCommands } from '../session.ts';
77
import { SessionStore } from '../../session-store.ts';
88
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
9+
import { AppError } from '../../../utils/errors.ts';
910

1011
function makeSessionStore(): SessionStore {
1112
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 () => {
312313
});
313314
assert.ok(response);
314315
assert.equal(response?.ok, true);
315-
assert.equal(ensureCalls, 1);
316+
assert.equal(ensureCalls, 0);
316317
if (response && response.ok) {
317318
assert.equal(response.data?.platform, 'android');
318319
assert.equal(response.data?.booted, true);
@@ -368,6 +369,128 @@ test('boot prefers explicit device selector over active session device', async (
368369
}
369370
});
370371

372+
test('boot --headless launches Android emulator when no running device matches', async () => {
373+
const sessionStore = makeSessionStore();
374+
const ensured: string[] = [];
375+
const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = [];
376+
const response = await handleSessionCommands({
377+
req: {
378+
token: 't',
379+
session: 'default',
380+
command: 'boot',
381+
positionals: [],
382+
flags: { platform: 'android', target: 'mobile', device: 'Pixel_9_Pro_XL', headless: true },
383+
},
384+
sessionName: 'default',
385+
logPath: path.join(os.tmpdir(), 'daemon.log'),
386+
sessionStore,
387+
invoke: noopInvoke,
388+
ensureReady: async (device) => {
389+
ensured.push(device.id);
390+
},
391+
resolveTargetDevice: async () => {
392+
throw new AppError('DEVICE_NOT_FOUND', 'No devices found');
393+
},
394+
ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => {
395+
launchCalls.push({ avdName, serial, headless });
396+
return {
397+
platform: 'android',
398+
id: 'emulator-5554',
399+
name: 'Pixel_9_Pro_XL',
400+
kind: 'emulator',
401+
target: 'mobile',
402+
booted: true,
403+
};
404+
},
405+
});
406+
407+
assert.ok(response);
408+
assert.equal(response?.ok, true);
409+
assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: true }]);
410+
assert.deepEqual(ensured, ['emulator-5554']);
411+
if (response && response.ok) {
412+
assert.equal(response.data?.platform, 'android');
413+
assert.equal(response.data?.id, 'emulator-5554');
414+
assert.equal(response.data?.device, 'Pixel_9_Pro_XL');
415+
}
416+
});
417+
418+
test('boot launches Android emulator with GUI when no running device matches', async () => {
419+
const sessionStore = makeSessionStore();
420+
const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = [];
421+
const response = await handleSessionCommands({
422+
req: {
423+
token: 't',
424+
session: 'default',
425+
command: 'boot',
426+
positionals: [],
427+
flags: { platform: 'android', target: 'mobile', device: 'Pixel_9_Pro_XL' },
428+
},
429+
sessionName: 'default',
430+
logPath: path.join(os.tmpdir(), 'daemon.log'),
431+
sessionStore,
432+
invoke: noopInvoke,
433+
ensureReady: async () => {},
434+
resolveTargetDevice: async () => {
435+
throw new AppError('DEVICE_NOT_FOUND', 'No devices found');
436+
},
437+
ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => {
438+
launchCalls.push({ avdName, serial, headless });
439+
return {
440+
platform: 'android',
441+
id: 'emulator-5554',
442+
name: 'Pixel_9_Pro_XL',
443+
kind: 'emulator',
444+
target: 'mobile',
445+
booted: true,
446+
};
447+
},
448+
});
449+
450+
assert.ok(response);
451+
assert.equal(response?.ok, true);
452+
assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]);
453+
if (response && response.ok) {
454+
assert.equal(response.data?.platform, 'android');
455+
assert.equal(response.data?.id, 'emulator-5554');
456+
assert.equal(response.data?.device, 'Pixel_9_Pro_XL');
457+
}
458+
});
459+
460+
test('boot --headless requires avd selector when device cannot be resolved', async () => {
461+
const sessionStore = makeSessionStore();
462+
let bootCalled = false;
463+
const response = await handleSessionCommands({
464+
req: {
465+
token: 't',
466+
session: 'default',
467+
command: 'boot',
468+
positionals: [],
469+
flags: { platform: 'android', target: 'mobile', serial: 'emulator-5554', headless: true },
470+
},
471+
sessionName: 'default',
472+
logPath: path.join(os.tmpdir(), 'daemon.log'),
473+
sessionStore,
474+
invoke: noopInvoke,
475+
ensureReady: async () => {},
476+
resolveTargetDevice: async () => {
477+
throw new AppError('DEVICE_NOT_FOUND', 'No devices found');
478+
},
479+
ensureAndroidEmulatorBoot: async () => {
480+
bootCalled = true;
481+
throw new Error('unexpected');
482+
},
483+
});
484+
485+
assert.ok(response);
486+
assert.equal(response?.ok, false);
487+
assert.equal(bootCalled, false);
488+
if (response && !response.ok) {
489+
assert.equal(response.error.code, 'INVALID_ARGS');
490+
assert.match(response.error.message, /boot --headless requires --device <avd-name>/);
491+
}
492+
});
493+
371494
test('appstate on iOS requires active session on selected device', async () => {
372495
const sessionStore = makeSessionStore();
373496
const sessionName = 'default';

src/daemon/handlers/session.ts

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ type ReinstallOps = {
4848
android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
4949
};
5050

51+
type EnsureAndroidEmulatorBoot = (params: {
52+
avdName: string;
53+
serial?: string;
54+
headless?: boolean;
55+
}) => Promise<DeviceInfo>;
56+
5157
const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE =
5258
'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "<name>" <app>).';
5359
const BATCH_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'target', 'device', 'udid', 'serial', 'verbose', 'out'];
@@ -226,6 +232,27 @@ async function resolveCommandDevice(params: {
226232
return device;
227233
}
228234

235+
function resolveAndroidEmulatorAvdName(params: {
236+
flags: DaemonRequest['flags'] | undefined;
237+
sessionDevice?: DeviceInfo;
238+
resolvedDevice?: DeviceInfo;
239+
}): string | undefined {
240+
const explicit = params.flags?.device?.trim();
241+
if (explicit) return explicit;
242+
if (params.resolvedDevice?.platform === 'android' && params.resolvedDevice.kind === 'emulator') {
243+
return params.resolvedDevice.name;
244+
}
245+
if (params.sessionDevice?.platform === 'android' && params.sessionDevice.kind === 'emulator') {
246+
return params.sessionDevice.name;
247+
}
248+
return undefined;
249+
}
250+
251+
const defaultEnsureAndroidEmulatorBoot: EnsureAndroidEmulatorBoot = async ({ avdName, serial, headless }) => {
252+
const { ensureAndroidEmulatorBooted } = await import('../../platforms/android/devices.ts');
253+
return await ensureAndroidEmulatorBooted({ avdName, serial, headless });
254+
};
255+
229256
const defaultReinstallOps: ReinstallOps = {
230257
ios: async (device, app, appPath) => {
231258
const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
@@ -442,6 +469,7 @@ export async function handleSessionCommands(params: {
442469
start: typeof startAppLog;
443470
stop: typeof stopAppLog;
444471
};
472+
ensureAndroidEmulatorBoot?: EnsureAndroidEmulatorBoot;
445473
resolveAndroidPackageForOpen?: (
446474
device: DeviceInfo,
447475
openTarget: string | undefined,
@@ -462,6 +490,7 @@ export async function handleSessionCommands(params: {
462490
start: startAppLog,
463491
stop: stopAppLog,
464492
},
493+
ensureAndroidEmulatorBoot: ensureAndroidEmulatorBootOverride = defaultEnsureAndroidEmulatorBoot,
465494
resolveAndroidPackageForOpen: resolveAndroidPackageForOpenOverride = resolveAndroidPackageForOpen,
466495
} = params;
467496
const dispatch = dispatchOverride ?? dispatchCommand;
@@ -555,13 +584,94 @@ export async function handleSessionCommands(params: {
555584
const flags = req.flags ?? {};
556585
const guard = requireSessionOrExplicitSelector(command, session, flags);
557586
if (guard) return guard;
558-
const device = await resolveCommandDevice({
559-
session,
587+
const normalizedPlatform = normalizePlatformSelector(flags.platform) ?? session?.device.platform;
588+
const targetsAndroid = normalizedPlatform === 'android';
589+
const wantsAndroidHeadless = flags.headless === true;
590+
const shouldUseFastAndroidSelectorLookup = targetsAndroid && !flags.target && Boolean(flags.device || flags.serial);
591+
const fallbackAvdName = resolveAndroidEmulatorAvdName({
560592
flags,
561-
ensureReadyFn: ensureReady,
562-
resolveTargetDeviceFn: resolveDevice,
563-
ensureReady: true,
593+
sessionDevice: session?.device,
564594
});
595+
const canFallbackLaunchAndroidEmulator = targetsAndroid && Boolean(fallbackAvdName);
596+
let device: DeviceInfo;
597+
let launchedAndroidEmulator = false;
598+
const fastLookupDevice = shouldUseFastAndroidSelectorLookup
599+
? await (async () => {
600+
const { resolveAndroidBootSelectorDevice } = await import('../../platforms/android/devices.ts');
601+
return await resolveAndroidBootSelectorDevice({
602+
deviceName: flags.device,
603+
serial: flags.serial,
604+
});
605+
})()
606+
: undefined;
607+
try {
608+
device = fastLookupDevice
609+
?? (await resolveCommandDevice({
610+
session,
611+
flags,
612+
ensureReadyFn: ensureReady,
613+
resolveTargetDeviceFn: resolveDevice,
614+
ensureReady: false,
615+
}));
616+
} catch (error) {
617+
const appErr = asAppError(error);
618+
if (targetsAndroid && wantsAndroidHeadless && !fallbackAvdName && appErr.code === 'DEVICE_NOT_FOUND') {
619+
return {
620+
ok: false,
621+
error: {
622+
code: 'INVALID_ARGS',
623+
message: 'boot --headless requires --device <avd-name> (or an Android emulator session target).',
624+
},
625+
};
626+
}
627+
if (!canFallbackLaunchAndroidEmulator || appErr.code !== 'DEVICE_NOT_FOUND' || !fallbackAvdName) {
628+
throw error;
629+
}
630+
device = await ensureAndroidEmulatorBootOverride({
631+
avdName: fallbackAvdName,
632+
serial: flags.serial,
633+
headless: wantsAndroidHeadless,
634+
});
635+
launchedAndroidEmulator = true;
636+
}
637+
if (targetsAndroid && wantsAndroidHeadless) {
638+
if (device.platform !== 'android' || device.kind !== 'emulator') {
639+
return {
640+
ok: false,
641+
error: {
642+
code: 'INVALID_ARGS',
643+
message: 'boot --headless is supported only for Android emulators.',
644+
},
645+
};
646+
}
647+
if (!launchedAndroidEmulator) {
648+
const avdName = resolveAndroidEmulatorAvdName({
649+
flags,
650+
sessionDevice: session?.device,
651+
resolvedDevice: device,
652+
});
653+
if (!avdName) {
654+
return {
655+
ok: false,
656+
error: {
657+
code: 'INVALID_ARGS',
658+
message: 'boot --headless requires --device <avd-name> (or an Android emulator session target).',
659+
},
660+
};
661+
}
662+
device = await ensureAndroidEmulatorBootOverride({
663+
avdName,
664+
serial: flags.serial,
665+
headless: true,
666+
});
667+
}
668+
await ensureReady(device);
669+
} else {
670+
const shouldEnsureReady = device.platform !== 'android' || device.booted !== true;
671+
if (shouldEnsureReady) {
672+
await ensureReady(device);
673+
}
674+
}
565675
if (!isCommandSupportedOnDevice('boot', device)) {
566676
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
567677
}

0 commit comments

Comments
 (0)