Skip to content

Commit 74eee5a

Browse files
authored
Fix Android boot completion and emulator launch modes (#138)
* Fix Android boot completion and headless emulator launch * Refactor Android boot fast lookup for testability * Avoid extra target probes in Android boot fast lookup * Simplify Android boot path in session handler * fix: enforce boot target and headless validation
1 parent cfc56e8 commit 74eee5a

10 files changed

Lines changed: 744 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: 203 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,207 @@ 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', 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', 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', 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+
494+
test('boot --headless rejects non-Android selectors', async () => {
495+
const sessionStore = makeSessionStore();
496+
let resolved = false;
497+
const response = await handleSessionCommands({
498+
req: {
499+
token: 't',
500+
session: 'default',
501+
command: 'boot',
502+
positionals: [],
503+
flags: { platform: 'ios', device: 'iPhone 17 Pro', headless: true },
504+
},
505+
sessionName: 'default',
506+
logPath: path.join(os.tmpdir(), 'daemon.log'),
507+
sessionStore,
508+
invoke: noopInvoke,
509+
ensureReady: async () => {},
510+
resolveTargetDevice: async () => {
511+
resolved = true;
512+
throw new Error('unexpected resolve');
513+
},
514+
ensureAndroidEmulatorBoot: async () => {
515+
throw new Error('unexpected emulator launch');
516+
},
517+
});
518+
519+
assert.ok(response);
520+
assert.equal(response?.ok, false);
521+
assert.equal(resolved, false);
522+
if (response && !response.ok) {
523+
assert.equal(response.error.code, 'INVALID_ARGS');
524+
assert.match(response.error.message, /headless is supported only for Android emulators/i);
525+
}
526+
});
527+
528+
test('boot keeps --target validation when emulator is fallback-launched', async () => {
529+
const sessionStore = makeSessionStore();
530+
let ensured = false;
531+
const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = [];
532+
const response = await handleSessionCommands({
533+
req: {
534+
token: 't',
535+
session: 'default',
536+
command: 'boot',
537+
positionals: [],
538+
flags: { platform: 'android', target: 'tv', device: 'Pixel_9_Pro_XL' },
539+
},
540+
sessionName: 'default',
541+
logPath: path.join(os.tmpdir(), 'daemon.log'),
542+
sessionStore,
543+
invoke: noopInvoke,
544+
ensureReady: async () => {
545+
ensured = true;
546+
},
547+
resolveTargetDevice: async () => {
548+
throw new AppError('DEVICE_NOT_FOUND', 'No Android TV devices found');
549+
},
550+
ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => {
551+
launchCalls.push({ avdName, serial, headless });
552+
return {
553+
platform: 'android',
554+
id: 'emulator-5554',
555+
name: 'Pixel_9_Pro_XL',
556+
kind: 'emulator',
557+
target: 'mobile',
558+
booted: true,
559+
};
560+
},
561+
});
562+
563+
assert.ok(response);
564+
assert.equal(response?.ok, false);
565+
assert.equal(ensured, false);
566+
assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]);
567+
if (response && !response.ok) {
568+
assert.equal(response.error.code, 'DEVICE_NOT_FOUND');
569+
assert.match(response.error.message, /matching --target tv/i);
570+
}
571+
});
572+
371573
test('appstate on iOS requires active session on selected device', async () => {
372574
const sessionStore = makeSessionStore();
373575
const sessionName = 'default';

0 commit comments

Comments
 (0)